A senior-level treatment. The goal is not "how to add a login form" — it is to understand where identity state lives, who is allowed to trust it, and what breaks under an adversary. Most Next.js auth tutorials are wrong in the same way: they treat middleware as a security boundary. It is not. Hold that thought through the whole chapter.
Every auth system answers three distinct questions. Conflating them is the root of most bugs.
| Question | Name | Concern |
|---|---|---|
| Who are you? | Authentication (authn) | Proving identity (password, OAuth, passkey) |
| What may you do? | Authorization (authz) | Permissions, roles, ownership |
| How do we remember you? | Session management | Carrying identity across stateless HTTP requests |
They have different failure modes. A correct login (authn) with a broken session layer still gets you owned. Authz checks that trust an unverified session are theater. Keep the three mentally separate and you will design better systems.
HTTP is stateless. The server finishes a request and remembers nothing. To make "logged in" persist, the client must present proof on every request. That proof is a credential carried in a cookie (almost always). The entire design space reduces to one question:
Where does the session state live — on the server, or inside the token itself?
This single axis splits the world into two families.
| Stateful (server holds state) | Stateless (token holds state) | |
|---|---|---|
| Cookie contains | Opaque random session ID | Self-contained signed token (JWT) |
| Server stores | Session record (Redis/Postgres) | Nothing (or a denylist) |
| Revocation | Trivial — delete the row | Hard — token is valid until it expires |
| Horizontal scaling | Needs shared session store | Stateless; any node can verify |
| Payload visibility | Nothing leaks; ID is opaque | Claims are readable (base64, not encrypted) |
| Per-request cost | A store lookup | A signature verification (cheap, local) |
| Failure mode | Store outage = everyone logged out | Stolen token = valid until expiry, no kill switch |
Neither is "better." The production answer is usually a hybrid: a stateless short-lived access token for speed, backed by a stateful long-lived refresh token for revocation. We build to that.
The cookie is the wire. If the cookie is wrong, the cryptography above it is irrelevant. A session cookie must be set with intent:
cookieStore.set('__Host-session', token, {
httpOnly: true, // JS cannot read it -> neutralizes XSS token theft
secure: true, // HTTPS only -> no plaintext over the wire
sameSite: 'lax', // not sent on cross-site POST -> primary CSRF defense
path: '/', // required for the __Host- prefix
maxAge: 60 * 60 * 24 * 7,
})
Attribute-by-attribute, and the attack each one closes:
HttpOnly — the cookie is invisible to document.cookie. This is why a session token in a cookie beats one in localStorage: an XSS payload can read all of localStorage instantly, but cannot touch an HttpOnly cookie. Rule: session material never goes in localStorage or sessionStorage.Secure — refuses to transmit over plain HTTP. Closes passive network sniffing / downgrade.SameSite — Lax (sent on top-level navigations, withheld on cross-site subrequests/POSTs), Strict (never cross-site, breaks inbound links while logged in), None (sent everywhere, requires Secure, needed only for genuine cross-site cookies). This is the first line of CSRF defense.Path / Domain — scope. Avoid setting a broad Domain unless you truly need subdomain sharing; a leaked subdomain then can't read the cookie.__Host- prefix — a browser-enforced contract: the cookie is rejected unless it has Secure, Path=/, and no Domain. This pins the cookie to the exact origin and defeats subdomain cookie-injection / fixation. Prefer __Host- for session cookies; use __Secure- when you must set a Domain.