React
Cookie sessions in the App Router: the safe minimum
An httpOnly cookie holding a signed token or an opaque ID, checked on the server on every request: the safe minimum for App Router sessions.
Key points
- The cookie carries a signed token or an opaque ID, always `httpOnly`, `secure` and `sameSite=lax`.
- Authorisation belongs in the server component or the action, not in middleware.
- A JWT cannot be revoked: if you need instant logout, use a sessions table.
A safe session in the App Router is an httpOnly, secure, sameSite=lax cookie holding a signed token or an opaque ID, plus a check on the server on every request. Middleware is not where you authorise.
What the cookie carries
Two real options: a self-contained signed token, or an opaque ID tied to a sessions table. The first needs no database but cannot be revoked; the second costs one query per request and revokes at once.
| Approach | Revocation | Cost per request | When it fits |
|---|---|---|---|
| Signed JWT | Only by rotating the key | Signature check, no query | Short sessions, few users |
| Opaque ID plus a table | Immediate, one row | One query, cacheable | Logout, bans, devices |
| Encrypted cookie | Through a token version | Decryption, no query | Small data in the cookie |
The minimum code
// app/actions/login.ts
"use server";
import { cookies } from "next/headers";
import { SignJWT } from "jose";
const chiave = new TextEncoder().encode(process.env.SESSION_SECRET);
export async function login(userId: string) {
const token = await new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(chiave);
const jar = await cookies();
jar.set("sessione", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}
Generate the key once and keep it in the environment: openssl rand -base64 32. If the key lands in the repository, every session is compromised and rotation is the only sensible answer.
In Next 15 cookies() is async: const jar = await cookies(). Forgetting the await gives a type error rather than a silent bug, which is why the mistake surfaces fast.
Middleware helps, but does not authorise
Middleware reads the cookie and can redirect quickly to /login. It cannot validate the token against the database and must not become the only check: anyone who copies the pattern without that distinction lets through requests that look authenticated. The full pattern is in middleware for authentication.
The EneUI catalogue indexes 111,985 components, 1,556 blocks and 12,808 icons from 1,472 libraries; the figure was measured on 1 November 2026 on ui.eneawork.it. At that page count, one requireUser() function read on the server is worth more than twenty checks scattered across components.
Components for the login screen
Three records worth using: the authentication blocks with complete forms to adapt, the ready login-01 block, and the form validation components for client-side errors.
To decide where the check belongs, action or route, read Route Handler or Server Action. Payload validation is covered in validating Server Actions, and the boundary between the two worlds in server-only and client-only.
The limit to budget for
A JWT cannot be revoked. If a user loses a device, the only lever is a shorter expiry or a move to table-backed sessions. Decide before you ship the login, not when it happens.
Frequently asked questions
JWT or a database session?
A JWT if the session is short and you never need to revoke it. A table-backed session if you need instant logout, bans or a device list. The cost is one query per request, often cacheable.
Where does the signing secret go?
In a server environment variable, never in <code>NEXT_PUBLIC_*</code>. A public variable ships in the client bundle and anyone can sign valid tokens.
Can middleware protect a page?
It can redirect, it cannot authorise. The real check belongs in the server component or the action, because middleware has no database access and a matcher mistake can skip it.
How do I log out?
With a Server Action that deletes the cookie via <code>jar.delete("sessione")</code> and, with table sessions, removes the row. A redirect on its own deletes nothing.
Does the cookie work in development without HTTPS?
With <code>secure: true</code> the browser drops it on <code>http://localhost</code> in some contexts. Set <code>secure: process.env.NODE_ENV === "production"</code> and keep production strict.