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.

0. Three questions, kept separate

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.

1. The session problem: HTTP forgets

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.

2. Cookies — the transport layer (get every attribute right)

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: