Next.js App Router
Authentication and Authorization in Next.js
Sessions with cookies, checking auth close to the data, protecting routes and actions, and the common mistakes.
On this page
- Words used in this lesson
- The three jobs, and where each one lives
- The lab app
- Step 1: the session cookie
- Step 2: the login Server Action
- Step 3: the Data Access Layer
- Step 4: the proxy, an optimistic check
- Step 5: the layout check that does not protect
- Step 6: authorization, and the IDOR check
- Step 7: Server Actions are public endpoints
- Step 8: route handlers are doors too
- Logging out, and the limit of stateless sessions
- When Cache Components is on
- Common mistakes
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: In Next.js, login is a cookie that a Server Action sets, and every protected thing must check that cookie again, as close to the data as possible. A proxy.ts redirect is only a quick first filter. A check in a layout is not enough. A Server Action is a public URL, so it needs its own check. I proved each of these with a running app, and I show the real output below.
Most auth bugs in Next.js are not about passwords or hashing. They are about where the check is. Someone puts the check in the layout, and a page below it is still open. Someone hides a button, and the Server Action behind it is still callable by anyone. Someone checks "are you logged in?" but forgets "is this document yours?". This lesson shows where checks go, and what happens when you put them in the wrong place.
This is lesson 5 of ten. It builds on Server Actions, Forms and Mutations (the login form is a Server Action) and on Route Handlers, Middleware (Proxy) and APIs (the proxy.ts file). The next lesson, Rendering Modes: Static, Dynamic, Streaming and Suspense, explains why reading a cookie makes a page dynamic. For the general theory of sessions, JWTs, XSS and CSRF, this lesson does not repeat it. See Sessions vs JWT, Where Do Tokens Live?, Passwords and Login and Access Control: IDOR, Roles and Mass Assignment. Here we put those ideas into the App Router.
What I ran / checked. Linux, Node.js v22.22.0, Next.js 16.3.5 (this repo's version), React 19.3.0, jose 6.2.12 (a JWT library), zod 4.6.6 (a validation library) and TypeScript. I built a small throwaway app in /tmp, ran next build and next start, and tested with curl and with Playwright and Chromium (real browser clicks, real cookies). Sources I read (in my own words): inside node_modules/next/dist/docs/01-app/, the guides "Authentication", "Authentication with Cache Components", "Data Security", "Server Actions", the proxy, cookies, unauthorized, forbidden and authInterrupts pages. Not run: I did not run the Cache Components version of the auth guide with a real session library, and I did not test any third-party auth library (Auth.js, Clerk and so on). I only list the ones the docs name. The authInterrupts feature is marked experimental in the docs, and I used it only for a 403 page. Timings and tokens vary from run to run.
Words used in this lesson
- Authentication (authN) means proving who you are. A login form with a password is authentication.
- Authorization (authZ) means deciding what you may do. "Can this user read this document?" is authorization.
- Session means the server remembering that you are logged in, across many requests.
- Cookie is a small value the browser stores and sends back with every request to the same site.
- HttpOnly is a cookie flag. It hides the cookie from JavaScript on the page, so a script cannot steal it.
- Secure is a cookie flag. It tells the browser to send the cookie only over HTTPS.
- SameSite is a cookie flag that limits when the browser sends the cookie on requests that start from other sites.
- JWT (JSON Web Token) is a small signed text token. Anyone can read it. Only the server that has the secret can make a valid one.
- Signing means adding a proof (a signature) to data, so the server can tell if someone changed it. Signing is not hiding.
- Optimistic check means a quick check that only reads the cookie and does not ask the database. It is fast, but it can be out of date.
- Secure check means a check that asks the database, so it is always current.
- Data Access Layer (DAL) is one place in your code (usually one folder) that reads data and checks who is asking. All other code goes through it.
- DTO (Data Transfer Object) means a small object with only the fields the caller may see, not the whole database row.
- IDOR means "insecure direct object reference". You are logged in, you change an ID in the URL, and you see someone else's data.
- Proxy is the
proxy.tsfile. It runs before a request is handled. In older versions it was calledmiddleware.ts. - server-only is a small package. If a file imports it and a Client Component imports that file, the build fails.
The three jobs, and where each one lives
The Next.js docs split auth into three jobs. I find that split very useful, because each job lives in a different place.
- Authentication. The user proves who they are. In code: a login form, a Server Action, a check of the password.
- Session management. The app remembers the user between requests. In code: a cookie that holds a signed token or a session ID.
- Authorization. The app decides what this user can see and do. In code: checks next to your data.
And there are six places where a check can live in an App Router app. Not all of them are equal:
| Place | Runs when | Good for | Not enough because |
|---|---|---|---|
proxy.ts | Before every matched request, including prefetches | A fast redirect for people who are clearly logged out | It only has the cookie. Its matcher can miss routes. It should not call the database |
layout.tsx | When the layout renders | Showing a user menu | A layout does not re-run when you move between pages below it (I prove this below) |
page.tsx | When the page renders | Protecting that page | Other entry points (actions, route handlers) are not covered |
| Data Access Layer | Every time data is read | The real check, near the data | Only works if all code uses it |
Route Handler (route.ts) | Every request to that endpoint | Protecting an API | It is a separate door. It needs its own check |
| Server Action | Every call to that action | Protecting a mutation | It is a public POST endpoint. It needs its own check |
The docs say it directly: do most security checks as close to the data source as possible. Keep that sentence in mind. We will test it.
The lab app
I built this small app. It has two users (alice and bob), each with one private document. The "database" is an array in a file, because the database is not the point.
app/ login/page.tsx, form.tsx login form (Client Component) dashboard/page.tsx needs login docs/[id]/page.tsx shows one document (needs ownership) weak/layout.tsx, a, b a deliberately bad pattern (layout-only check) admin/page.tsx, buttons.tsx two Server Actions, one unsafe and one safe api/profile/route.ts API: who am I api/docs/[id]/route.ts API: one documentlib/ session.ts sign and verify the cookie dal.ts Data Access Layer actions.ts login and logout actions db.ts the fake databaseproxy.ts optimistic redirect for /dashboardStep 1: the session cookie
I use a stateless session: the cookie itself holds the data, signed with a secret. The docs name two kinds of session. Stateless means the data is in the cookie. Database sessions mean the cookie holds only an ID, and the data is in your database. The docs recommend a library such as Jose or iron-session for stateless sessions.
First, a secret. The docs suggest making one with openssl rand -base64 32 and keeping it in an environment variable. I used a fixed test value in .env.local. This is a lab value only.
SESSION_SECRET=dev-only-secret-change-me-0123456789abcdefHere is lib/session.ts, shortened:
import 'server-only'import { SignJWT, jwtVerify } from 'jose'import { cookies } from 'next/headers'const key = new TextEncoder().encode(process.env.SESSION_SECRET)export type SessionPayload = { userId: string; role: 'user' | 'admin' }export async function encrypt(payload: SessionPayload, expiresIn = '7d') { return new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime(expiresIn) .sign(key)}export async function decrypt(token: string | undefined = '') { try { const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] }) return payload as unknown as SessionPayload } catch { return null }}export async function createSession(payload: SessionPayload) { const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) const token = await encrypt(payload) const store = await cookies() store.set('session', token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', expires, })}export async function deleteSession() { const store = await cookies() store.delete('session')}Four things to notice:
cookies()is async in this version. You mustawaitit.decryptnever throws. A bad token givesnull. That makes every caller simple: "no session means not logged in".algorithms: ['HS256']is passed tojwtVerify. This pins the one algorithm you accept, so a token that claims a different algorithm is refused.- The cookie is set on the server, with
httpOnly,secure,sameSiteand an expiry. The docs list the same five options.
Now I want to see these flags in a real browser, not just trust my code. I logged in with Playwright and asked Chromium for the cookie:
cookie flags: [ { name: 'session', httpOnly: true, secure: true, sameSite: 'Lax', path: '/', valueStart: 'eyJhbGciOiJI...' }]document.cookie: ""httpOnly: true means that JavaScript on the page cannot see it. I checked that by reading document.cookie in the page. It was an empty string, even though the cookie exists and is sent to the server. That is the whole point of HttpOnly. If an attacker gets a script into your page (XSS), the script still cannot read the session. (How XSS and CSRF work is in Where Do Tokens Live?.)
secure: true on http://localhost worked in Chromium, because browsers treat localhost as a secure context. Some other browsers and tools may not accept it. In real production you use HTTPS, so it is fine.
Signed is not secret
The docs name the function encrypt, and I copied the name. But a SignJWT token is signed, not encrypted. Anyone who has the cookie can read what is inside. I decoded the middle part of my token with base64:
payload: {"userId":"u1","role":"user","iat":1791774592,"exp":1792379392}The signature stops people from changing it. It does not stop people from reading it. The docs say the payload should hold the minimum: a user ID and a role. No email, no phone, no password. If you need real secrecy, use a library that encrypts (iron-session does), or keep the data in the database and put only an ID in the cookie.
Step 2: the login Server Action
The login form is a Client Component. It calls a Server Action with useActionState. You learned this in lesson 3. Here is the action, shortened:
'use server'const LoginSchema = z.object({ email: z.string().email({ error: 'Enter a valid email.' }), password: z.string().min(1, { error: 'Password is required.' }),})export async function login(_prev: LoginState, formData: FormData) { const parsed = LoginSchema.safeParse({ email: formData.get('email'), password: formData.get('password') }) if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors } const user = users.find((u) => u.email === parsed.data.email && u.password === parsed.data.password) // Same message for "no such user" and "wrong password". if (!user) return { message: 'Invalid email or password.' } await createSession({ userId: user.id, role: user.role }) redirect('/dashboard')}My lab compares plain passwords. Real code must not. I wrote u.password === ... only to keep the lab short. A real app stores a hash made with a slow password hash (the docs example uses bcrypt) and compares with the library function. See Passwords and Login.
Two small things matter. First, one message, "Invalid email or password.", for both cases. If the message said "no such user", an attacker could test which emails exist. Second, redirect() comes after createSession. redirect works by throwing a special error. Anything after it does not run.
Here is what the browser showed when I tried three logins (real output from my Playwright script):
validation: Enter a valid email.bad login: Invalid email or password.email field after failed submit: ""after login url: http://localhost:3105/dashboard | Hello alice@example.comThe third line is a trap that surprised me. After the action returned an error, the email field was empty. React 19 resets an uncontrolled form after an action finishes. So a user who mistyped a password must type the email again. The fix: have the action return the values it received (return { message, email }) and put them in defaultValue on the inputs. This is a user-experience bug, not a security bug, but every login form hits it.
Step 3: the Data Access Layer
Now the heart of the lesson. Put the real check in one file, and make every data read go through it. The docs call this a Data Access Layer. Here is mine:
import 'server-only'import { cache } from 'react'import { cookies } from 'next/headers'import { redirect } from 'next/navigation'import { decrypt } from './session'export const verifySession = cache(async () => { const token = (await cookies()).get('session')?.value const session = await decrypt(token) if (!session?.userId) redirect('/login') return { userId: session.userId, role: session.role }})// Return only what the UI needs (a DTO), never the whole row.export const getUser = cache(async () => { const { userId } = await verifySession() const u = users.find((x) => x.id === userId) if (!u) return null return { id: u.id, email: u.email }})export async function getDoc(id: string) { const { userId } = await verifySession() const d = docs.find((x) => x.id === id) if (!d) return { status: 'missing' as const } if (d.ownerId !== userId) return { status: 'forbidden' as const } return { status: 'ok' as const, doc: { id: d.id, title: d.title } }}Three ideas live in this small file.
1. import 'server-only'. If a Client Component ever imports this file (even by accident through a chain of imports), the build must fail. I tested it. I made a Client Component import a file that has import 'server-only'. The real build output:
Error: 'server-only' cannot be imported from a Client Component module> 1 | import 'server-only' | ^^^^^^^^^^^^^^^^^^^^It should only be used from a Server Component.Import traces: Client Component Browser: ./a8/lib/secret.ts [Client Component Browser] ./a8/app/leak2/page.tsx [Client Component Browser]The build stopped. Without that one line, the code would have shipped to the browser (and process.env.SECRET_TOKEN would have been undefined there, silently). A loud failure is far better than a quiet one.
2. React's cache(). verifySession is wrapped in cache. That means that inside one request, no matter how many components call it, it runs once. I tested it with a page that calls verifySession() twice and getUser() twice. I put a console.log inside verifySession. The server log after one request showed it ran 1 time. So you can call verifySession() freely in every component that needs the user. You do not have to pass the user down as props. (cache lasts for one request only. It is not a shared cache between users. That is lesson 2.)
3. A DTO. getUser returns { id, email }. The fake database row also has password and passwordHash. Those never leave the DAL. If a Server Component passes a whole row to a Client Component as a prop, every field goes into the page's data (lesson 1). Returning a small object makes that mistake hard to make.
Step 4: the proxy, an optimistic check
Now the proxy.ts. In Next.js 16 this is the new name for what used to be middleware.ts. I checked: if you still name the file middleware.ts, the build prints a warning and still works:
⚠ The "middleware" file convention is deprecated. Please use "proxy" instead. npx @next/codemod@canary middleware-to-proxy .The docs say the proxy is good for "optimistic checks": read the cookie, and redirect if clearly logged out. It runs before the page, so it is fast and the user never sees the page's loading state. My proxy:
import { NextRequest, NextResponse } from 'next/server'import { decrypt } from '@/lib/session'export default async function proxy(req: NextRequest) { const path = req.nextUrl.pathname const token = req.cookies.get('session')?.value const session = await decrypt(token) if (path.startsWith('/dashboard') && !session?.userId) { return NextResponse.redirect(new URL('/login', req.nextUrl)) } return NextResponse.next()}export const config = { matcher: ['/((?!api|weak|admin|_next/static|_next/image|.*\\.png$).*)'],}It only reads the cookie. No database. The docs warn that the proxy also runs on prefetch requests (when a <Link> loads a page in the background), so a slow check here slows the whole app. They also say that the proxy runs on the Node.js runtime in this version, so you must check that your session library works there. jose did.
Now I tested it with curl, with no cookie:
$ curl -i localhost:3105/dashboardHTTP/1.1 307 Temporary Redirectlocation: /loginA real HTTP redirect, before any page code ran. Then I made four bad tokens with a small script and sent each as the cookie. The good one is a control:
good dashboard: 200 api/profile: 200expired dashboard: 307 api/profile: 401wrong dashboard: 307 api/profile: 401 (signed with a different secret, role "admin")none dashboard: 307 api/profile: 401 (header says alg "none", no signature)- expired was a validly signed token whose
exptime was in the past.jwtVerifyrefused it. - wrong claimed
role: 'admin'but was signed with another secret. Refused. This is the point of signing. - none is the classic "algorithm none" attack: a token that says "I need no signature". Because I pinned
algorithms: ['HS256'], it was refused.
(api/profile is outside the proxy matcher. It decides for itself, and it answers 401. That is correct for an API: a redirect to a login page makes no sense for a program calling an API.)
Why the proxy is not enough
Look again at the matcher in my proxy. I excluded api, weak and admin. In a real app, you would not do that on purpose. But the lesson is that the matcher is a list, and lists have holes. Add a new top-level route and forget the list, and nothing protects it. The proxy also only sees the URL and the cookie. It cannot know that docs/d2 belongs to bob. The docs say the proxy "should not be your only line of defense".
The proxy is a filter, not a lock. Use it to send logged-out users to the login page quickly. Then check again in the DAL. If you remove the proxy tomorrow, your data must still be safe.
Step 5: the layout check that does not protect
Many tutorials put the check in layout.tsx: "the dashboard layout checks the session, so everything below it is safe". That sounds right and is wrong. I built it on purpose in app/weak/:
// app/weak/layout.tsx (BAD PATTERN)export default async function WeakLayout({ children }: { children: React.ReactNode }) { await verifySession() return <section><p id="layout-ran">layout checked at {new Date().toISOString()}</p>{children}</section>}// app/weak/b/page.tsx (no check of its own)export default function B() { return <main><h1 id="secret">Weak B: SECRET DATA, no check in this page</h1></main> }First, a fresh visit with no cookie. That works as you hope:
$ curl -i localhost:3105/weak/bHTTP/1.1 307 Temporary Redirectlocation: /loginSo the layout does protect a full page load. Now the interesting test. I logged in with a real browser, opened /weak/a, deleted the cookie (as if the session expired, or the user logged out in another tab), and then clicked a <Link> to /weak/b:
weak/a layout: layout checked at 2026-10-12T03:08:56.237Zafter cookie cleared, client nav to B: Weak B: SECRET DATA, no check in this pagelayout text unchanged (not re-run): layout checked at 2026-10-12T03:08:56.237Zhard reload url: http://localhost:3105/loginRead it slowly:
- With a valid cookie, the layout rendered at
...56.237Z. - I removed the cookie and clicked a link to
/weak/b. - The secret page showed its data. The layout did not run again (same timestamp). Nobody checked.
- Only a hard reload (a full page load) sent the user to
/login.
This happens because of what you learned in lesson 0: a layout stays mounted when you move between its pages. On a client-side navigation, Next.js asks the server only for the parts that changed (the page), not for the layout. The page had no check of its own, so it showed its data. The docs warn about this: layouts do not re-render on navigation, so do not rely on them for auth checks.
The fix is not hard. Put the check where the data is read (the DAL), and the page is protected no matter how you reach it. Even if you also keep the layout check for a nicer redirect, the page must not depend on it.
Step 6: authorization, and the IDOR check
Being logged in is not enough. Alice is logged in. Should she see Bob's document? The DAL function getDoc handles it: if the document's ownerId is not the current user, return 'forbidden'.
I tested the API version and the page version as alice:
api doc d1: 200 {"id":"d1","title":"Alice's private plan"}api doc d2 (bob): 403 {"error":"forbidden"}/docs/d1 200 Alice's private plan/docs/d2 403 403 - not yours/docs/zzz 404On the page, I called forbidden() (from next/navigation) which renders my app/forbidden.tsx with a real 403 status. That function is experimental. In this version you must turn on experimental: { authInterrupts: true } in next.config.ts, or it will not work. For a stable setup, you can return a notFound() or redirect instead.
Which is better for someone else's document: 403 or 404? Both are valid. A 403 says "this exists, but not for you". A 404 says "nothing here". If knowing that a document exists is itself a leak (for example private patient IDs), return 404 for both "missing" and "not yours". In my lab the DAL returns different statuses, so each caller can choose. See Access Control: IDOR, Roles and Mass Assignment for the full theory.
Step 7: Server Actions are public endpoints
This is the check that people forget most. A Server Action looks like a normal function you call from a button. Under the hood, it is an HTTP POST endpoint. If someone knows its ID, they can call it with curl. They do not need your button, your page, or your proxy.
I wrote two actions that delete a document. One checks nothing. One checks login and ownership:
'use server'// BAD: relies on the page hiding the button.export async function deleteDocUnsafe(id: string) { const i = docs.findIndex((d) => d.id === id) if (i >= 0) docs.splice(i, 1) return { deleted: i >= 0, remaining: docs.length }}// GOOD: checks login and ownership inside the action.export async function deleteDocSafe(id: string) { const { userId } = await verifySession() const i = docs.findIndex((d) => d.id === id && d.ownerId === userId) if (i < 0) return { deleted: false, remaining: docs.length } docs.splice(i, 1) return { deleted: true, remaining: docs.length }}I opened the admin page in a browser that was not logged in, clicked the button once, and used Playwright to capture the request the browser made. It was a POST with a header named next-action (the action's ID) and a body ["d2"]. Then I sent the same request from curl, with no cookie at all:
$ curl -X POST localhost:3105/admin \ -H "Next-Action: 401b6cc8c8013107e43330cfa4f7b70c1cb953e9ff" \ -H "Content-Type: text/plain;charset=UTF-8" -d '["d1"]'0:{"a":"$@1","f":"","q":"","i":false,"b":"X0vcxlyg8K7JoDvU-yz3R"}1:{"deleted":true,"remaining":1}HTTP 200It deleted Alice's document. No login. No page. Just a POST. Then the safe action, again with no cookie:
safe, no cookie:HTTP/1.1 200 OKx-action-redirect: /login;pushThe safe action called verifySession(), found no session, and redirected to login. Nothing was deleted. A made-up action ID gets Server action not found. with status 404, but real IDs are in your page's JavaScript, so you cannot treat the ID as a secret.
Hiding a button is not security. Treat every Server Action like a public API route. Inside the action: check login, check ownership, validate the arguments (they come from the browser), and return only what the caller needs. The docs list the same rules in their data-security guide.
What about CSRF?
CSRF means another website tricks your logged-in browser into sending a request to your site. The docs say Server Actions are safer here for two reasons. They only accept POST, and SameSite cookies are the default in modern browsers. In addition, Next.js compares the Origin header with the Host header, and rejects the call if they differ. I tested that. Same call, with a faked Origin:
Origin http://localhost:3105 -> 200 ... {"deleted":true,"remaining":1}Origin https://evil.example -> 500 ... E{"digest":"883207488@E80"}The server log said: x-forwarded-host header with value localhost:3105 does not match origin header with value evil.example from a forwarded Server Actions request. Aborting the action. and Error: Invalid Server Actions request. If you run behind a reverse proxy or a different domain, you list the allowed hosts in serverActions.allowedOrigins. (Note: a script that is not a browser can send any Origin it likes. This check protects against browsers being tricked. It does not replace your login check.)
Step 8: route handlers are doors too
A route.ts is a separate door. It does not inherit anything from a page or layout. My /api/profile handler:
export async function GET() { const session = await decrypt((await cookies()).get('session')?.value) if (!session) return Response.json({ error: 'unauthenticated' }, { status: 401 }) const u = users.find((x) => x.id === session.userId)! return Response.json({ id: u.id, email: u.email })}Without a cookie: 401. With alice's cookie: 200 and only { id, email }. Note that I return a small object, not the user row. And for a route handler, return a status code, not redirect('/login'). The caller is usually code.
Logging out, and the limit of stateless sessions
Logout is cookies().delete('session'), then a redirect. This clears the cookie in the browser.
But here is the honest limit of a stateless session. If someone copied the cookie before logout, that copy is still a valid, signed token until it expires (7 days in my lab). The server has no list to cross it off. Also, if you put role: 'admin' in the token and later remove the admin role in the database, the old token still says admin until it expires. This is the main trade-off:
| Stateless (signed cookie) | Database session (cookie has only an ID) | |
|---|---|---|
| Check cost | No database read | One database read |
| Log out everywhere | Hard (wait for expiry, or keep a deny-list) | Easy (delete the rows) |
| Change a role now | Not until the token expires | At once |
| Good for | Simple apps, short expiry | Anything that needs "sign out all devices" and instant changes |
The docs describe both and suggest database sessions for advanced needs, such as logging out of all devices. A common middle path is a short token expiry (minutes) plus a refresh step. For the theory, see Sessions vs JWT.
Use a library if you can. The docs say plainly that rolling your own is complex, and they list auth libraries: Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js (Auth.js), Ory, Stack Auth, Supabase, Stytch and WorkOS. They list Iron Session and Jose for session management. I did not test any of them. What I built here is for learning where the checks go. The same placement rules apply when a library does the hard parts.
When Cache Components is on
If you turn on cacheComponents: true (lesson 2 and lesson 6), reading the cookie becomes a request-time read. It cannot be part of the prebuilt static shell. I tried reading cookies() in a page with no <Suspense> around it and ran next build. The real error:
Error: Route "/bad": Next.js encountered uncached or runtime data during prerendering.`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered ...Ways to fix this: - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access ...So with Cache Components, the user-aware part of the page goes inside a <Suspense> boundary and streams in. The docs also describe a directive named use cache: private, which can cache a per-user result in the browser only, never on the server. The docs say a plain use cache cannot read cookies(). If you want to cache something derived from the session on the server, read the user ID first and pass it in as an argument, so it becomes part of the cache key. I did not run that part. The key safety rule is the one from lesson 2: never cache user-specific data in a shared cache without the user in the key.
Common mistakes
- Checking auth only in a layout. (Proved above: client navigation skipped it.)
- Trusting the proxy alone. (Matcher holes, and it knows nothing about ownership.)
- Not checking inside Server Actions because "only logged-in users see the button".
- Checking "logged in" but not "owner". (IDOR.)
- Returning a whole database row to the client. (Use a DTO.)
- Putting secrets or personal data in a signed (not encrypted) token.
- Forgetting
awaitoncookies(). - Setting a cookie while a page renders. Cookies can only be set in a Server Function or Route Handler (HTTP does not allow headers after streaming starts).
- Using a
NEXT_PUBLIC_name for a secret (lesson 8). - Telling the user which part of the login was wrong.
Try it
Build the tiny version yourself.
- Make
lib/session.tswithcreateSessionanddecryptusingjose. PutSESSION_SECRETin.env.local. - Make a login Server Action that sets the cookie and redirects to
/dashboard. - Make
/dashboardread the user through averifySessionfunction wrapped incache. Call it twice and log inside it. How many times does it log per request? - Log in with a browser. In DevTools, open Application, Cookies. What flags are on
session? In the console, typedocument.cookie. What do you see? - Add a
proxy.tsthat redirects/dashboardto/loginwhen the cookie is missing. Usecurl -iwith and without a cookie. - Make a Server Action
deleteThing(id)with no check. Find itsnext-actionID in the Network tab, and call it withcurland no cookie. What happens? Now addverifySession()and call it again. - Put your only check in
app/dashboard/layout.tsx. Open two pages under it, clear the cookie, and click a<Link>between them. Does the second page show its data?
Answers
- Done when
jwtVerifyaccepts your own token and givesnullfor a changed one. - After login you land on
/dashboardand the browser has asessioncookie. - Once, even when called twice.
cacheshares the result inside one request. HttpOnly,Secure,SameSite=Lax.document.cookiedoes not showsessionat all.- Without a cookie:
307withlocation: /login. With a good cookie:200. - Without a check, the delete works with no login. After adding
verifySession(), you are redirected to login (headerx-action-redirect) and nothing is deleted. - Yes, it can. A client-side navigation does not re-run the layout. Move the check into the data read.
Interview questions
What is the difference between authentication, session management and authorization? (Must know)
Authentication proves who you are, for example with a password. Session management remembers that between requests, usually with a cookie. Authorization decides what you may do or see. In Next.js they live in different places: authentication in a Server Action, the session in a cookie set with cookies(), and authorization next to the data, in a Data Access Layer. The most common bug is to do the first two and forget the third.
Where do you put auth checks in the App Router? (Senior signal)
As close to the data as possible. I make a server-only Data Access Layer with a verifySession() function, and every read goes through it. I also check inside each Server Action and each Route Handler, because they are separate entry points. I use proxy.ts only as a fast first filter, and I treat layouts as UI, not security. If someone deleted my proxy and my layouts, the data should still be safe.
Is proxy.ts enough to protect a route? (Trap)
No. It only has the URL and the cookie, so it can do an optimistic check, but it cannot know if a document belongs to the user. Its matcher can miss a route. It also runs on prefetch requests, so it should not call the database. The docs say it should not be the only line of defense. In my test the proxy correctly redirected a logged-out user with a 307, and refused expired, wrongly signed and alg: none tokens, but I would still check again in the DAL.
A teammate puts the auth check in the dashboard layout. Is that safe? (Trap)
Not by itself. A layout stays mounted when you navigate between pages below it, so it does not re-render on a client-side navigation. I tested it. I logged in, removed the cookie, and clicked a link to a sibling page. The layout did not run again, and the page showed its data. A full page load would have redirected, but client navigation did not. The check must be in the page or, better, in the data access function.
Are Server Actions protected because only logged-in users see the button? (Trap)
No. A Server Action is a public POST endpoint. I captured the request from my admin button, replayed it with curl with no cookie, and the unsafe action deleted a document. The real action ID is visible in the page's network traffic. So each action must check login, check ownership, and validate its arguments. Treat it like an API route.
How would you store a session in Next.js? (Must know)
In a cookie that the server sets with cookies() inside a Server Action or Route Handler. The cookie should be HttpOnly (so scripts cannot read it), Secure (HTTPS only), SameSite (usually lax), have an expiry, and have a path. I checked these flags in a real browser, and document.cookie was empty. What goes inside is either a signed token with minimal data (user ID and role) or just a session ID that points to a database row.
Stateless cookie session or database session? (Senior signal)
Stateless is simple and has no database read, but I cannot revoke a copied token before it expires, and a role change does not apply until the token expires. A database session costs one read per check, but I can delete the row to log out everywhere and changes apply at once. For a simple app with short expiry, stateless is fine. For anything with "sign out all devices", admin roles or sensitive data, I choose a database session. A signed token is also readable, so I keep personal data out of it.
Why import 'server-only' and React's cache() in the DAL? (Senior signal)
server-only makes the build fail if a Client Component ever imports the file. I tested it, and the build stopped with an import trace. That turns a silent leak into a loud error. cache() memoizes a function for one request, so verifySession() can be called from many components and runs once. I confirmed one run for four calls. It is per request, not a shared cache between users.
A logged-in user opens another user's document by changing the ID. What do you return? (Senior signal)
First I make sure the check exists. This is IDOR. The data function compares the document's owner to the session user. Then I choose the status. 403 says "it exists, but not yours". 404 hides that the document exists, which is better if the existence is private. I would be consistent. In Next.js, forbidden() can render a 403 page, but it is experimental and needs authInterrupts, so I would check the version.
How does CSRF protection work for Server Actions? (Senior signal)
The docs give layers. Actions only accept POST. Cookies default to SameSite. Next.js also compares the Origin header to the Host (or X-Forwarded-Host) and rejects a mismatch. I tested it: a call with Origin: https://evil.example failed with "Invalid Server Actions request", and the same call with the right origin worked. Behind a proxy or another domain I would set serverActions.allowedOrigins. This protects against tricked browsers. It does not replace a login check, because a script can fake the header.
How does cacheComponents: true change how you read the session? (Senior signal)
Reading the cookie is request-time data, so it cannot be in the prebuilt shell. It must sit inside a <Suspense> boundary, and it streams in. I saw the build error when I read cookies() without one. You can cache work derived from the session, but a plain use cache cannot read cookies(). The docs show two ways: use cache: private (cached only in the browser), or reading the user ID outside and passing it in as an argument so it becomes part of the cache key. The danger to avoid is a shared cache entry that leaks one user's data to another.
You put role: 'admin' in the cookie token. An admin is demoted. What happens? (Trap)
With a stateless token, the old token still says admin until it expires, because the server has no list to cross it off. I would shorten the expiry, or look up the role in the database for sensitive actions (a secure check), or use a database session. This is why the docs split checks into optimistic (cookie only) and secure (database). Use the cookie for UI hints and the database for anything that matters.
Key takeaways
- Auth has three jobs: authentication, session management, authorization. Authorization is the one people forget.
- Set the session cookie on the server with
HttpOnly,Secure,SameSiteand an expiry. A signed token is readable, so keep it small and free of private data. - Put the real check in a server-only Data Access Layer, wrapped in
cache(), returning small DTOs. proxy.tsis a fast optimistic filter. It is not a lock, and it must not call the database.- A layout check is skipped on client-side navigation. I proved it.
- Server Actions and Route Handlers are public doors. Check login, ownership and arguments inside each one. I deleted a document from
curlwith no cookie to prove it. - Next.js checks
OriginagainstHostfor actions, which helps against CSRF, but it does not replace your own checks. - Stateless sessions cannot be revoked early. Use short expiry or database sessions when that matters.
- With Cache Components, session reads go inside
<Suspense>, and never share a cache entry between users. - Prefer a maintained auth library for the hard parts, and keep the placement rules from this lesson.
What's next
Next: Rendering Modes: Static, Dynamic, Streaming and Suspense. You just saw that reading a cookie turns a route into ƒ (dynamic). The next lesson shows how Next.js decides, what the browser receives while a slow part is still loading, and why the HTTP status can no longer change once streaming has started. Practise now: add proxy.ts and a DAL to a small app, then try to break it with curl the way I did.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Passwords and Login: Hashing, Sessions, MFA and Rate Limits · Security for Developers
- API Authentication and Authorization: API Keys, OAuth 2.0 and Scopes · API Design
- Authentication and Authorization on the Server · Backend in Depth
Part of the study guide
Related series