Claude Code with NextAuth: Providers, Sessions, Callbacks
Why NextAuth needs more CLAUDE.md scaffolding than most libraries
NextAuth, now packaged as Auth.js v5, is a flexible authentication layer. Flexibility is the problem. The library lets you pick a session strategy, mix and match providers, customise every callback in the auth lifecycle, and decide whether your database, your JWT, or both hold the source of truth. Every one of those decisions changes which patterns are correct and which quietly break security.
Claude Code knows NextAuth well. It can scaffold a Google provider, write a credentials provider, set up the magic link flow, and bolt middleware onto your matcher. What it cannot infer is your specific posture: whether sessions live in Postgres or in a signed cookie, whether the jwt callback or the session callback owns role enrichment, which routes are public, and what your multi-tenant scoping looks like. Without that posture written down, Claude defaults to whatever the official docs showed most recently, which mixes patterns across versions and produces auth flows that look right and fail in subtle ways.
This guide covers the CLAUDE.md configuration and patterns that anchor Claude Code to one coherent NextAuth implementation. If you have not set up Claude Code yet, the Claude Code setup guide covers installation. For an alternative authentication stack with a similar CLAUDE.md model, Claude Code with Clerk is a useful comparison.
The NextAuth CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every session. For a NextAuth-protected application it has to declare: Auth.js version, framework, where the auth handler lives, which providers are enabled, session strategy, where callbacks are defined, how middleware is mounted, and the hard rules that prevent insecure auth code.
# NextAuth (Auth.js v5) authentication rules
## Stack
- Next.js 15.x (App Router only)
- next-auth 5.x (Auth.js), @auth/drizzle-adapter 1.x
- Drizzle ORM 0.36.x with Postgres, TypeScript 5.x strict
## Project structure
- auth.ts: NextAuth() config, exports auth, handlers, signIn, signOut
- auth.config.ts: edge-safe config (providers minus DB adapter, callbacks for middleware)
- middleware.ts: imports auth.config.ts, runs at the edge
- app/api/auth/[...nextauth]/route.ts: re-exports handlers
- lib/db/schema/auth.ts: users, accounts, sessions, verificationTokens
- lib/auth-helpers.ts: requireUser, requireRole, requireTenant
## Environment (read from .env.local, never inline)
- AUTH_SECRET (32+ chars), AUTH_URL, AUTH_TRUST_HOST=true on Vercel
- AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
- AUTH_RESEND_KEY, EMAIL_FROM (for magic links)
- DATABASE_URL
## Session strategy
- strategy: "database" for this project (durable sessions, server-side revocation)
- maxAge: 30 days, updateAge: 24 hours
## Public routes (allowlist, not denylist)
- /, /login, /register, /verify-request, /api/auth/*, /pricing, /blog/*
## Hard rules
- NEVER call auth() in client components, use useSession()
- NEVER read user id, role, or tenant from request body, always from auth()
- NEVER mutate session in the session callback without sourcing from DB or trusted JWT
- NEVER skip CSRF protection on credentials provider POSTs
- ALL mutations must verify session.user.id matches the record owner (IDOR)
- Public routes are an explicit allowlist in middleware, never a denylist
- AUTH_SECRET is required in every environment, including preview deploys
Four rules in this template prevent the failures Claude produces most often without it.
The public route allowlist rule matters because Claude defaults to "block these paths" when conventions are unclear. A denylist fails open the moment you ship a new admin route and forget the matcher. An allowlist fails closed. Every new route is protected unless deliberately exposed.
The server vs client helper rule prevents Claude importing auth from auth.ts into a 'use client' component. This compiles. It can even appear to work in dev because the bundler tree-shakes server code unevenly. It breaks in production. The rule pushes Claude towards useSession and getSession for the client.
The session callback rule is the single most useful entry on this list. The session callback runs on every request that reads the session, including middleware on the edge. Mutating session.user.role based on data Claude pulls from a fresh DB query inside this callback is tempting and wrong, because middleware does not have a DB connection on the edge. The rule anchors role and tenant enrichment in the jwt callback (database strategy uses the adapter row directly), not the session callback.
The IDOR rule is the same rule from any auth-heavy project. Every mutation must verify the authenticated session.user.id matches the record's owner. Claude generates the check on every Server Action when the rule is in place, and trusts whatever userId arrives in the request body when it is not.
Provider setup: OAuth, credentials, magic links
NextAuth supports OAuth, credentials, and email-based magic links as three distinct provider categories. Each has its own security posture, and Claude tends to blur them together without explicit guidance.
Add a providers section to CLAUDE.md:
## Providers (auth.ts)
### Canonical config
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import Resend from "next-auth/providers/resend";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/auth";
import { eq } from "drizzle-orm";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
session: { strategy: "database", maxAge: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24 },
pages: { signIn: "/login", verifyRequest: "/verify-request" },
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
allowDangerousEmailAccountLinking: false,
}),
Resend({
apiKey: process.env.AUTH_RESEND_KEY,
from: process.env.EMAIL_FROM,
}),
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (raw) => {
const parsed = z
.object({ email: z.string().email(), password: z.string().min(8) })
.safeParse(raw);
if (!parsed.success) return null;
const [user] = await db
.select()
.from(users)
.where(eq(users.email, parsed.data.email))
.limit(1);
if (!user || !user.passwordHash) return null;
const valid = await bcrypt.compare(parsed.data.password, user.passwordHash);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),
],
});
The allowDangerousEmailAccountLinking: false setting matters more than the name suggests. With it set to true, a user who signs up with email and password and then signs in with Google using the same email address gets their accounts merged automatically. This is a documented account takeover vector when one of the two providers does not verify the email. Claude defaults to true in some scaffolds because it removes a friction point during demos. The rule keeps it false and surfaces account linking through an explicit settings flow where the user proves ownership of both accounts before they merge.
The credentials provider's authorize function is where Claude generates the most insecure code without guidance. The pattern in CLAUDE.md anchors three things. First, every input is parsed through Zod before touching the database, which prevents type confusion and basic injection. Second, the password comparison is bcrypt.compare against a stored hash, never a string equality check. Third, failure returns null rather than throwing, because thrown errors leak into the response body and can fingerprint whether an email exists. A timing-attack-resistant authorize function pairs all three: parse with Zod, look up the user with a constant-time comparison wrapping bcrypt, return null on any failure mode. Claude generates the right shape every time when the pattern is in CLAUDE.md, and generates an if (password === user.password) check at least once in three projects when it is not.
The Resend provider replaces the older nodemailer pattern most NextAuth tutorials still show. Magic links are stateless by default: the token in the email is the credential. The CLAUDE.md template assumes a deliverable-by-default provider with no SMTP server to configure. If your project sends magic links through a different transactional email service, the pattern stays the same; only the provider import changes. One subtle rule worth adding: magic link expiry should be short, typically 10 to 15 minutes, because the email itself is the credential and an unread email in an inbox is a credential at rest. NextAuth defaults to 24 hours, which is too long for most production applications.
Session strategy: JWT vs database
This is the decision that shapes the rest of the implementation, and Claude cannot read your mind. JWT sessions are stateless: the user's identity lives in a signed cookie, middleware can read it at the edge with no database call, and revocation is impossible without a denylist. Database sessions are stateful: every request hits your database to validate the session row, but you can revoke a session by deleting one row.
For most B2B applications, database sessions are the right call. They give you immediate revocation, accurate "active sessions" lists, and one source of truth for session state. For high-traffic public products with rare auth-state mutations, JWT sessions reduce latency.
Add a session strategy section to CLAUDE.md:
## Session strategy decision
### Database sessions (this project)
- Pros: immediate revocation, server-side session list, one source of truth
- Cons: DB hit on every request that needs the user
### JWT sessions (alternative)
- Pros: no DB hit, works on the edge without a DB driver
- Cons: revocation requires denylist, role changes need re-sign-in or callback refresh
### Configured here
session: { strategy: "database", maxAge: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24 }
### For edge middleware compatibility (auth.config.ts)
- Edge middleware cannot use the DB adapter
- Split auth.config.ts (edge-safe) from auth.ts (full config with adapter)
- Middleware imports auth.config.ts only
- Server-side reads use auth() from auth.ts (DB-backed)
The split-config pattern is the single most important NextAuth pattern in 2026. Auth.js v5 runs middleware on the edge runtime by default, where Node modules including most database drivers are unavailable. Without the split, Claude will generate a single auth.ts that imports the Drizzle adapter, then the middleware fails to compile.
The pattern is two files. auth.config.ts exports a config object with providers (minus the credentials provider, which often needs the DB) and the callbacks middleware needs. auth.ts imports auth.config.ts, adds the adapter and credentials provider, and exports the runtime auth, signIn, and signOut. Middleware imports auth.config.ts only and gets a callable auth for route protection.
For the Drizzle schema NextAuth needs, the patterns in Claude Code with PostgreSQL cover the underlying database setup, and the Drizzle-specific conventions in Claude Code with Prisma translate directly to Drizzle's schema-first approach.
Callbacks: signIn, jwt, session
NextAuth callbacks fire in a specific order at specific moments, and each one has a different contract. Getting the contracts right is the difference between a working auth flow and one that silently returns empty sessions.
Add a callbacks section to CLAUDE.md:
## Callbacks (auth.ts and auth.config.ts)
### signIn: runs once per sign-in, before account is linked
- Return true to allow, false to deny, or a URL string to redirect
- Use for: email allowlists, domain restrictions, custom verification
### jwt: runs every time a JWT is created or updated
- Database strategy: runs on signIn only, before the session is created
- JWT strategy: runs on every request that touches the session
- Enrich token with role, tenant, anything middleware needs at the edge
### session: runs every time a session is read
- Receives the token (JWT strategy) or the user row from DB (database strategy)
- Shape the session object the client sees
- NEVER do DB work here under database strategy, the user row is already loaded
### Canonical implementation
callbacks: {
async signIn({ user, account, profile }) {
if (account?.provider === "google") {
const email = profile?.email;
if (!email?.endsWith("@yourcompany.com") && !await isInvitedEmail(email)) {
return false;
}
}
return true;
},
async jwt({ token, user, trigger, session }) {
if (user) {
token.id = user.id;
token.role = user.role ?? "member";
token.tenantId = user.tenantId;
}
if (trigger === "update" && session) {
// explicit update() call from client, e.g. after role change
token.role = session.role;
}
return token;
},
async session({ session, user, token }) {
// database strategy: use user; jwt strategy: use token
if (user) {
session.user.id = user.id;
session.user.role = user.role;
session.user.tenantId = user.tenantId;
} else if (token) {
session.user.id = token.id as string;
session.user.role = token.role as string;
session.user.tenantId = token.tenantId as string;
}
return session;
},
authorized({ auth, request }) {
// used by middleware via auth.config.ts
return !!auth;
},
},
The signIn callback is where domain restriction and invitation gating live. Claude defaults to returning true unconditionally, which is fine for public products and wrong for invite-only B2B tools. With the pattern in CLAUDE.md, Claude generates the gating check against either a static domain allowlist or a database invitation lookup. The callback runs before the user row is persisted, which is the right moment to reject sign-ups: returning false here means no orphaned user record sits in your database from a failed invitation check.
The jwt callback's trigger === "update" branch handles the client calling update({ role: "admin" }) after, say, an admin grants a user a new role. Without explicit handling, the change does not propagate until the next sign-in. With it, the next request picks up the new role. Claude tends to miss this branch when it scaffolds the callback from official docs alone, because the official docs treat it as an advanced topic when in practice it is a baseline requirement for any role-aware application.
The session callback signature differs by strategy. Database strategy receives user (the row from the adapter's users table). JWT strategy receives token (the decoded JWT). Conditional handling for both is what makes the codebase migration-safe if you switch strategies later. Claude often writes only the strategy currently in use, which works but locks in the choice.
The authorized callback is what middleware reads. Keep it tight. Returning !!auth enforces "must be signed in" and lets the matcher decide which routes that applies to. Putting custom route logic here couples middleware to specific paths and makes Claude generate sprawl.
Middleware and route protection
NextAuth middleware in Auth.js v5 is simpler than the v4 wrapper most tutorials still show. The pattern is one middleware.ts that imports the edge-safe auth.config.ts, calls NextAuth() against it, and uses the returned auth as the middleware export.
Add a middleware section to CLAUDE.md:
## Middleware (middleware.ts and auth.config.ts)
### auth.config.ts (edge-safe, no DB adapter)
import type { NextAuthConfig } from "next-auth";
import Google from "next-auth/providers/google";
export default {
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isPublic = ["/login", "/register", "/verify-request", "/pricing"].some((p) =>
nextUrl.pathname === p,
);
const isPublicPrefix = ["/api/auth", "/blog"].some((p) =>
nextUrl.pathname.startsWith(p),
);
if (nextUrl.pathname === "/" || isPublic || isPublicPrefix) return true;
return !!auth;
},
},
} satisfies NextAuthConfig;
### middleware.ts
import NextAuth from "next-auth";
import authConfig from "./auth.config";
export const { auth: middleware } = NextAuth(authConfig);
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|ttf|woff2?)$).*)",
],
};
The authorized callback in auth.config.ts is the centralised gate. Every protected route resolves to a single boolean decision in one place. Claude tends to inline route checks across multiple files when this is not consolidated, which produces drift: a new route added in one file forgets the matcher in another.
The matcher regex excludes static assets explicitly. Most NextAuth tutorials show a matcher that runs on every request including static files, which adds edge runtime overhead to every PNG. The exclusion is one regex Claude will copy verbatim when it sits in CLAUDE.md.
The public route detection splits between exact matches and prefix matches deliberately. /login is exact (so /login-admin does not match), while /api/auth is a prefix (so all OAuth callbacks pass). Claude will sometimes use startsWith for both, which lets /login-internal slip through. Pinning the pattern in CLAUDE.md prevents that.
For server-side route protection inside Server Components and Server Actions, the pattern stays simple:
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export async function requireUser() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
return session.user;
}
export async function requireRole(role: "admin" | "member") {
const user = await requireUser();
if (user.role !== role) redirect("/forbidden");
return user;
}
export async function requireTenant() {
const user = await requireUser();
if (!user.tenantId) redirect("/select-tenant");
return { ...user, tenantId: user.tenantId };
}
These helpers live in lib/auth-helpers.ts. Once Claude knows they exist, every Server Action and Route Handler starts with the appropriate require* call. Without them, Claude inlines if (!session?.user) checks repeatedly, which is fine until somebody forgets one. The TypeScript-strict shape of these helpers composes with the broader patterns in Claude Code with TypeScript.
CSRF, multi-tenant scoping, and the hard rules
NextAuth handles CSRF for its own routes through a double-submit cookie pattern, but the protection only applies to routes mounted under /api/auth/*. Your own mutation routes get no CSRF protection from NextAuth alone. The Server Actions model in App Router gives you CSRF protection by default through the action's origin check, but only if you stay inside Server Actions and avoid hand-rolled fetch POSTs.
Add a CSRF and multi-tenant section to CLAUDE.md:
## CSRF protection
- Server Actions: protected by default (App Router origin check)
- Route Handlers: NOT protected by default, verify origin or use a CSRF token
- NextAuth /api/auth/*: protected by double-submit cookie (handled by Auth.js)
- Credentials provider POST: protected when using the built-in signIn() helper
### Origin check pattern for custom POST routes
import { headers } from "next/headers";
export async function POST(req: Request) {
const h = await headers();
const origin = h.get("origin");
const host = h.get("host");
if (!origin || new URL(origin).host !== host) {
return new Response("Forbidden", { status: 403 });
}
// proceed
}
## Multi-tenant scoping
### Every read scopes by tenantId
const projects = await db
.select()
.from(projectsTable)
.where(eq(projectsTable.tenantId, session.user.tenantId));
### Every mutation verifies tenant match (IDOR + tenant isolation)
export async function deleteProject(projectId: string) {
const user = await requireTenant();
const [project] = await db
.select()
.from(projectsTable)
.where(eq(projectsTable.id, projectId))
.limit(1);
if (!project || project.tenantId !== user.tenantId) {
throw new Error("Not found");
}
return db.delete(projectsTable).where(eq(projectsTable.id, projectId));
}
### Hard rules
- NEVER trust tenantId from request body
- NEVER assume a single global tenant, always scope
- ALWAYS verify tenant match before any mutation, even reads if data is sensitive
- Cross-tenant operations require an explicit elevated role and audit log
The origin check pattern for Route Handlers exists because Claude sometimes generates a custom POST endpoint for, say, a webhook receiver or a public form submission, and without the explicit pattern in CLAUDE.md it skips the origin verification. The check is a few lines and stops the simplest CSRF attempts cold.
The multi-tenant pattern is the same as the IDOR pattern, generalised. Every read filters by tenantId. Every mutation verifies the record's tenantId matches the user's. Claude will sometimes drop the filter when generating a new query if the surrounding code does not show it, especially in larger refactors where it touches multiple files. Putting the canonical shape in CLAUDE.md is what makes Claude generate the filter every time.
The "no global tenant" rule prevents a common shortcut. In single-tenant mode (everyone is in one org), it is tempting to skip the tenantId filter. Then you add a second tenant later, ship the change, and discover that historical queries return mixed data. The rule keeps the filter in place from day one.
Permission hooks for auth-touching scripts
Auth projects accumulate scripts: backfilling local users from a third-party identity provider, rotating session secrets, deleting test users, granting roles. They range from safe to catastrophic. Permission hooks gate them explicitly; the mechanism is documented in Claude Code permissions.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm dev*)",
"Bash(pnpm build*)",
"Bash(pnpm test*)",
"Bash(pnpm typecheck*)",
"Bash(pnpm tsx scripts/list-users.ts*)",
"Bash(pnpm tsx scripts/list-sessions.ts*)"
],
"deny": [
"Bash(pnpm tsx scripts/delete-user.ts*)",
"Bash(pnpm tsx scripts/revoke-all-sessions.ts*)",
"Bash(pnpm tsx scripts/rotate-auth-secret.ts*)",
"Bash(pnpm tsx scripts/grant-role.ts*)"
]
}
}
Read-only scripts and the standard build, test, and dev commands are allowed without prompting. Anything that mutates user state, revokes sessions, or rotates secrets is gated behind explicit confirmation. The broader workflow patterns are covered in Claude Code best practices.
Hard rules and what to review manually
Claude Code generates excellent NextAuth code in several areas. The provider configuration, the credentials authorize function with Zod and bcrypt, the split auth.ts / auth.config.ts pattern, the middleware matcher, and the requireUser helpers are all consistently correct when the patterns are in CLAUDE.md.
Three areas warrant manual review.
The first is the signIn callback. Domain restrictions, invitation gating, and account linking policy are project-specific. Claude can scaffold the structure but cannot infer your policy. Treat every change to this callback as a security review.
The second is callback ordering when you migrate strategies. Switching from JWT to database sessions changes which callback receives which arguments. Claude will scaffold the new pattern but may leave the old branch in place, producing dead code or, worse, divergent behaviour between cold-start and warm-start sessions. Audit the callbacks after any strategy change.
The third is the public route allowlist in auth.config.ts. Every new public route is a deliberate decision. Adding a route to the allowlist exposes it to the internet; treat each addition as a review step and confirm the route handles its own auth where the data warrants it. The same review applies to environment variables: every preview deployment needs AUTH_SECRET set, and every provider's client credentials need to point at the right OAuth app for that environment. For server-state propagation in protected routes, the patterns in Claude Code with React and Claude Code with Next.js compose directly with the helpers above.
Building auth that fails closed
The NextAuth CLAUDE.md in this guide produces an implementation where middleware protects routes by default, the auth handler stays edge-safe, callbacks enrich tokens at the right moment, magic links and OAuth and credentials all flow through the same session shape, multi-tenant scoping holds on every query, and destructive scripts are gated behind permission hooks.
The underlying principle is the same as any framework integration: Claude Code performs at the level of context you give it. A project without a NextAuth CLAUDE.md produces Claude that mixes Auth.js v4 and v5 patterns, skips the edge-safe config split, leaves callbacks empty, and trusts client-side role state. A project with the configuration above fails closed by default, and every new route, provider, or callback Claude generates inherits that posture.
For the mechanics of how CLAUDE.md is read at session start, see CLAUDE.md explained. For the App Router conventions this NextAuth setup builds on, Claude Code with Next.js covers the foundation, and Claude Code with environment variables covers the AUTH_SECRET and provider credential handling that composes with this guide. Claudify includes a NextAuth-specific CLAUDE.md template, pre-configured for the edge-safe split, callback ordering, multi-tenant scoping, and IDOR prevention.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify