Skip to content
intermediate Phase 7 · Route Handlers and APIs

Protecting Endpoints with Sessions

Verify credentials before handling any API request.

45m
0 problems
Topic Progress 0%

The Browser Is Not Your Only Client: Why Every Handler Verifies the Session Itself

The orders page shows four rows because the component filters the API response to order.userId === me.id. A pentester opens DevTools, reads the endpoint from the network tab, calls /api/orders directly with a colleague's account cookie, and receives every row. The UI hid the data; nothing on the server ever checked it. This is the single most common authorization failure in App Router applications, and it comes from a category error: a route handler is a public URL, not a private method of your page.

Anyone who can construct the request can call it — the browser's address bar, curl, a script on another origin, or a page the user happens to have open. Hiding a button, disabling a form, or only calling the endpoint from an authenticated component changes nothing about that. The rule that follows is worth stating as an invariant: every handler verifies identity and permission itself, before reading a body or touching data. Not "most handlers" — one forgotten export is the incident.

Where should that verification live so it cannot be forgotten? Two candidates exist, and only one is reliable:

Location What it buys you What it costs you
middleware.ts with a matcher one place, redirects unauthenticated users before the route opt-in per path; drift when new routes are added; runs on the Edge runtime with a limited API surface
a shared requireSession() helper called by each handler impossible to forget if you call it; full Node runtime for DB lookups one line per handler

Next.js itself warns against treating middleware as the security boundary: middleware is a routing convenience that can be misconfigured, and a matcher that misses a newly added folder silently exposes it. The defensible design uses middleware for convenience (redirect a browser to /login early) and a helper inside the handler for guarantee.

Server Actions deserve the same warning, because teams apply the rule to route handlers and forget them: a "use server" function is a POST endpoint with a generated URL. React fences make accidental cross-site invocation harder, but the function must still authenticate and authorize internally — it is exactly as reachable as a route handler.

Identity is only half the check. Authentication answers "who is calling"; authorization answers "is this caller allowed to touch this resource" — and the second is where ownership bugs live. An authenticated user requesting /api/orders/ord_99 must not learn that a colleague's order id exists; the safe query constrains both conditions at once: db.orders.findFirst({ where: { id, userId: session.sub } }), returning 404 when nothing matches. The unsafe version fetches by id, compares userId in JavaScript, and forgets the comparison on the branch that updates the row three months later. Query-level constraints survive refactors; ad-hoc in-code checks do not.

The shared helper is small, and small is the point:

// lib/session.ts
export async function requireSession(): Promise<Session | NextResponse> {
  const token = (await cookies()).get('session')?.value;
  if (!token) return unauthorized('no_session');
  try {
    return (await jwtVerify(token, secret())).payload as Session;
  } catch {
    return unauthorized('invalid_session');
  }
}

// app/api/orders/route.ts
export async function GET() {
  const auth = await requireSession();
  if (auth instanceof NextResponse) return auth;   // 401 already built
  return NextResponse.json(await listOrders(auth.sub));
}

Returning the error response from the helper — instead of throwing — keeps control flow boring and auditable: every protected handler opens with the same two lines, and a grep for requireSession is a complete audit of which endpoints are protected.

401, 403, and the Retry Contract: Error Responses a Client Can Act On

A mobile client retries a failed request three times with backoff, because that is what a good client does. Your endpoint returns 401 with an empty body for a dead session, and the client dutifully retries — three more calls that will also fail, all of them logged as "server errors" in the dashboard. The status was right; the contract was missing. Designing auth failures is designing for the client's decision tree.

The two statuses mean different things and must not be interchangeable:

Status Meaning Client action
401 Unauthorized Who are you? No valid credential — missing, expired, or tampered reauthenticate; do not retry the same request
403 Forbidden We know who you are; you may not do this fail permanently; do not retry; optionally surface a permission message

An authenticated non-admin hitting DELETE /api/users/9 gets 403 — retrying cannot help because the credential is fine. A user whose session expired gets 401 — retrying cannot help either, but for the opposite reason: the credential itself must be replaced. The HTTP spec pairs 401 with a WWW-Authenticate header; browsers' fetches do not auto-prompt for your cookie sessions, but including the header keeps the response honest.

The body is where you remove the ambiguity:

function unauthorized(code: string) {
  return NextResponse.json(
    { error: { code, message: 'Session expired — sign in again' } },
    { status: 401, headers: { 'www-authenticate': 'Bearer' } },
  );
}
// usage: unauthorized('session_expired') | unauthorized('no_session')

On the client, that becomes one rule at the fetch layer: a 401 clears local state and routes to /login (with a next parameter for where to return), a 403 renders a permission message, anything else follows the normal error path. Because API routes are not pages, never redirect a programmatic caller with a 307 to /login — it turns a JSON client's failure into HTML garbage; send the status and let the client decide.

The wrapper that implements it once for the whole app:

export async function api(input: string, init?: RequestInit) {
  const res = await fetch(input, { credentials: 'same-origin', ...init });
  if (res.status === 401) { redirectToLogin(); throw new AuthError(); }
  if (res.status === 403) throw new PermissionError(await res.json());
  if (!res.ok) throw new ApiError(res.status, await res.json());
  return res.status === 204 ? null : res.json();
}

Every caller inherits the reauthentication behavior; no component ever writes res.status === 401 by hand, which is how the contract stays true as the codebase grows.

Three hardening details round out the picture. Revocation: an unexpired JWT cannot be killed early without extra machinery — keep a tokenVersion (or session id) on the user row and compare it during verification, so logout and password changes bump the version and every outstanding token fails from the next request on. Brute force: rate-limit the login route per IP and per account — an in-memory counter works for one instance; Redis or an edge KV is required for a fleet, and the login handler itself must never reveal whether the email exists (uniform 401, constant behavior). Credentialed CORS: if another origin calls your API with cookies, the handler must echo that exact origin in access-control-allow-origin with access-control-allow-credentials: true — the wildcard * is rejected by browsers when credentials are on.

Get these three right and the endpoint is safe to expose; forget the verification boundary from chapter one and no amount of status-code craft matters.

Quiz

1. A team protects /api/orders by hiding the link in the sidebar and by a middleware matcher on /api/:path*. A new folder app/api/admin/route.ts is added without updating the matcher. What is exposed?

Question 1 options

2. Why must JWT verification pin the algorithm (e.g. algorithms: ['HS256'])?

Question 2 options

3. An authenticated non-admin calls DELETE /api/users/9. Which response is correct?

Question 3 options

4. You must invalidate every outstanding session JWT immediately after a user changes their password. What mechanism works with stateless tokens?

Question 4 options

Flashcards

Question

Why is middleware.ts not a sufficient security boundary for route handlers?

Answer

It is opt-in via a path matcher: any route not covered (including newly added folders) runs unauthenticated, and its Edge runtime limits what you can check. Use it for early redirects; guarantee security with a requireSession()-style call inside every handler and server action.

Question

What does jwtVerify actually check, and what does jwtDecode not check?

Answer

jwtVerify checks the signature against the secret, the exp/iat timestamps, and the pinned algorithm — returning verified claims. jwtDecode only base64-decodes the payload: those claims are attacker-writable and must never drive authorization decisions.

Question

401 vs 403 — how should each client react?

Answer

401: no valid credential (missing/expired/tampered) — reauthenticate, do not retry the same request. 403: valid credential, insufficient permission — fail permanently and surface a permission message. Returning the right one lets the client's fetch wrapper branch once.

Question

How do you revoke a stateless JWT before it expires?

Answer

Server-side versioning: store a tokenVersion (or last-session-id) on the user row, embed it in the token, and reject tokens whose version no longer matches during verification. Bumping the version invalidates every outstanding token from the next request.

Question

What cookie attributes protect a session token, and what does each do?

Answer

httpOnly — blocks document.cookie access, killing XSS token theft; secure — HTTPS only; sameSite=lax — not sent on simple cross-site POSTs, blunting CSRF (none requires secure and sends everywhere); path — scopes where it is sent. Set via res.cookies.set(...) or the cookies() API.

Revision Notes

Key Takeaways

  • 1. A route handler is a public URL — server actions likewise — so the verification boundary lives inside each handler, with middleware only saving a round trip
  • 2. One requireSession() helper makes protection grep-able and removes the forgot-to-check failure mode entirely
  • 3. jwtVerify checks signature, expiry, and pinned algorithm; decoding without verifying means trusting attacker-written claims
  • 4. 401 means replace the credential, 403 means stop asking — returning the right one is what lets a client stop retrying
  • 5. Stateless JWTs cannot be revoked early without a server-side version check; password changes and logouts need that lever

Interview Tips

  • • "Where do you check auth in a Next.js app?" — inside every handler/action via one helper; explain why middleware is a convenience (matcher drift, edge limits) and that server actions are POST endpoints too
  • • "JWT or database sessions?" — name the trade: JWT = stateless verification, no lookup, hard to revoke; DB/session-id = revocation and audit for free, one lookup per request (mitigate with Redis)
  • • "How would you detect a tampered token?" — signature verification fails; mention alg pinning to stop 'none'/HS-vs-RS confusion and never trusting decoded claims
  • • "Your API returns 401 but the mobile client loops. What is wrong?" — the contract: no structured body or the client retries 401 blindly; return a machine-readable code and document that 401 means reauthenticate once, then stop

Cheat Sheet

Protecting Endpoints with Sessions

The invariant: every handler (and server action) verifies identity + permission itself before reading the body. Hiding UI and middleware matchers are conveniences, not guarantees.

// lib/session.ts
export async function requireSession() {
  const token = (await cookies()).get('session')?.value;
  if (!token) return unauthorized('no_session');
  try { return (await jwtVerify(token, secret(), { algorithms: ['HS256'] })).payload; }
  catch { return unauthorized('invalid_session'); }
}

Cookie: httpOnly, secure, sameSite: 'lax', path: '/', maxAge ≈ token exp. Secret ≥ 32 random bytes, env-only (SESSION_SECRET).

JWT facts: payload carries sub/role/exp; jwtVerify checks signature + timestamps + pinned algorithm; jwtDecode checks nothing; rotating the secret kills all sessions; tokenVersion on the user row gives early revocation.

Status Means Client should
401 credential missing/expired/tampered reauthenticate, not retry
403 authenticated, not permitted fail permanently
429 rate limited back off

Hardening: uniform 401 on login (never reveal email existence), rate-limit login per IP + account (Redis for fleets), credentialed CORS echoes the exact origin (never *).

Continue Learning