Authentication in Next.js with Auth.js
Auth.js (formerly NextAuth) is the de facto standard for Next.js authentication. Setup, session access, route protection and the JWT-versus-database decision.
Authentication is a solved problem that teams keep un-solving. For most Next.js applications, Auth.js — the project formerly known as NextAuth — is the sensible default: OAuth providers, sessions, CSRF protection and cookie handling out of the box, with escape hatches when you need them. Version 5 is built around the App Router, and the integration is pleasingly small once you see the shape of it.
The setup
Everything hangs off one configuration file that exports the handlers and helpers you will use across the app. A catch-all Route Handler exposes the sign-in, callback and sign-out endpoints.
// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
callbacks: {
session({ session, token }) {
if (token.sub) session.user.id = token.sub;
return session;
},
},
});The exported auth function is the workhorse: it reads the session anywhere on the server — pages, layouts, Route Handlers, Server Actions — with no context providers required.
Two pieces of configuration bite in deployment rather than development: AUTH_SECRET must be set, and stable, in every environment, and each OAuth provider needs its callback URL registered per environment — previews included. Auth bugs that only appear on staging are almost always one of these two.
Reading the session on the server
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function AccountPage() {
const session = await auth();
if (!session?.user) redirect('/login');
return <AccountView user={session.user} />;
}Client components that need session state can use the useSession hook behind a SessionProvider, but reach for it sparingly — most session reads belong on the server, where they add no bundle weight and cannot be tampered with. Anything secret should never reach the client session object at all; the callbacks decide what crosses that line.
Where to enforce protection
There are three places to check auth, and they are not interchangeable:
- Middleware: fast optimistic redirects for whole sections; keep the check to cookie presence
- Pages and Server Actions: the authoritative check — do this even when middleware also guards the route
- Layouts: convenient but insufficient alone, because layouts do not re-render on every navigation within them
The layout caveat catches people out. Treat layout checks as UX sugar and put the real enforcement in each page or, better, in the data-access functions themselves — then no route can forget it.
JWT or database sessions?
Auth.js supports both strategies. JWT sessions are stateless and fast: nothing to look up per request, which suits middleware nicely. Database sessions add a query but give you instant revocation and a live view of active sessions. Our rule of thumb: JWTs for content sites and low-risk apps, database sessions when you need to force sign-out on demand — anything handling money or sensitive records. Whichever you choose, keep session payloads minimal; a JWT is not a cache for your user profile.
A word on passwords
Auth.js supports a credentials provider for email and password, but the maintainers steer you away from it for good reason: you inherit hashing, reset flows, rate limiting and breach handling. If password login is a hard requirement, budget for doing those properly — or put a managed identity service behind Auth.js and let it carry that weight.
Authentication is the one feature where boring, standard and well-trodden beats clever every single time.
Auth.js will not fit every case — heavy enterprise SSO or fine-grained permissions may justify a dedicated provider — but it is the right starting point far more often than not.
Building something where auth has to be right first time? STRCLI has done this dance a few times — we are happy to help.