Decode Is Not Verify: Checking the Signature Before You Read a Single Claim
A JWT is three base64url segments joined by dots — eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI... .aZ3... — and the middle segment is just encoded JSON. Nothing about it is encrypted, and nothing about it is trusted until the third segment checks out. Paste any JWT into jwt.io and you can read the claims immediately, including claims you wrote yourself. A JWT signature is checked with the secret before any claim inside it is trusted, and skipping that order is how an app ends up treating role: "ADMIN" from a URL parameter as fact.
// lib/session-token.ts — jose works in the edge runtime; node:crypto does not
import { jwtVerify, type JWTPayload } from "jose"
const secret = new TextEncoder().encode(process.env.AUTH_SECRET!)
export type SessionClaims = JWTPayload & {
sub: string
email: string
role: "USER" | "ADMIN"
}
export async function verifySessionToken(
token: string | undefined,
): Promise<SessionClaims | null> {
if (!token) return null
try {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"], // pin it: never let the token choose its algorithm
clockTolerance: 5, // seconds of skew between edge nodes
})
return payload as SessionClaims
} catch (err) {
// ERR_JWT_EXPIRED, ERR_JWS_SIGNATURE_VERIFICATION_FAILED, malformed input
return null // every failure means the same thing: signed out
}
}
Read that function as the security boundary it is. jwtVerify recomputes the HMAC over header.payload and compares it to the signature segment; a single changed byte in the payload — one role, one user id, one expiry — produces a different HMAC and ERR_JWS_SIGNATURE_VERIFICATION_FAILED. The secret never leaves the server, so anyone who wants a valid token has to obtain AUTH_SECRET first.
Two mistakes show up repeatedly in code review:
- Decoding instead of verifying.
jwtDecode(token)(oratobsplit on dots) parses claims with no signature check at all. It is fine for logging a token you already verified and wrong for every authorization decision. If a function returns a role from a token, it had better bejwtVerify. - Letting the token pick the algorithm. The header carries
alg, and a token announcingalg: noneor swapping HMAC for RSA turns verification into a rubber stamp — the algorithm-confusion family of attacks. Pinalgorithms: ["HS256"]and the header becomes untrusted metadata.
The failure handling matters as much as the crypto. Expired, tampered, wrong-secret, and truncated tokens are all common — a deploy that rotates AUTH_SECRET invalidates every outstanding session in one move — so treat every error as "no session" rather than branching on error type. Branching invites a fallback path where a signature error falls through to a permissive default, and that is the kind of code an interviewer circles.
One design decision sits above the code: symmetric versus asymmetric signing. The HS256 setup above signs and verifies with the same shared secret, which is fine when your Next.js server is the only verifier. The moment a second service must check the token — a separate API, a worker, a partner — sharing AUTH_SECRET doubles the blast radius of a leak, so sign with a private key (RS256 or EdDSA) and hand verifiers the public key via createRemoteJWKSet, which fetches and caches your JWKS document. Only the issuer can mint tokens; verifiers cannot.
Where this runs is constrained too: middleware executes on the edge runtime, which has Web Crypto but no node:crypto and no database driver. jose is pure Web Crypto, so the same verifySessionToken runs unchanged in middleware.ts, in a route handler, and inside a server component — one implementation, three call sites. Keep it in a single module; duplicated verification logic is how two paths end up with different clockTolerance and one of them accepts a token the other rejects.
exp, Sliding Expiry, and the Revocation Problem You Cannot Hand-Wave
Once verification is solid, the next question is how long the answer stays true. A JWT's lifetime is expressed in its claims, and exp is the one that bounds the damage:
| Claim | Meaning | Verified how |
|---|---|---|
sub |
Subject — the user id | Trusted only after the signature checks |
iat |
Issued at | Basis for exp and for audit logs |
exp |
Expiration instant | Compared against now, ± clockTolerance |
nbf / aud / iss |
Not-before, audience, issuer | Optional guards against cross-service reuse |
The exp claim bounds how long a token remains acceptable after issuance, and because it lives inside the signed payload, an attacker cannot extend it: rewriting exp invalidates the signature. What they can do is use a stolen, still-valid token for as long as it lasts. That asymmetry is the entire revocation discussion:
session: {
strategy: "jwt",
maxAge: 60 * 60 * 24 * 7, // exp is 7 days out
updateAge: 60 * 60 * 12, // refresh the cookie at most twice a day
}
One asymmetry is worth internalizing: the browser's Max-Age and the token's exp are independent. The browser will happily keep sending a cookie whose payload expired ten minutes ago, and a proxy or test client can forge both fields, so the server's jwtVerify exp check is the authority — never the presence of the cookie or its Max-Age attribute.
The interview question this sets up is deliberate: "A user's admin rights are revoked while their token is still valid. When does access stop?" With a plain JWT the honest answer is at exp — you cannot recall a signed statement. The three responses, in order of cost:
- Short
expplus refresh rotation. Access token lives minutes, refresh cookie lives days, each refresh issues a new token and invalidates the old one. Bounded exposure, at the price of a refresh path and reuse detection. - Server-side denylist. Keep the stateless fast path, but check a revocation list for high-value events (password change, forced logout, demotion). The check costs a lookup, so the advantage over a database session narrows.
- Database session. Delete the row. If you are doing this anyway for revocation, you are running Session Reads in Server Components's database strategy with extra steps — and that is a legitimate conclusion, not a failure.
| Design | Revocation | Per-request cost | Typical exp |
|---|---|---|---|
| Long-lived JWT | Wait out exp |
Signature check only | Days to weeks |
| Short JWT + rotating refresh | Rotation invalidates the old token | Signature + refresh traffic | Minutes |
| Opaque database session | Delete the row | Cookie → database lookup | Days, with sliding renewal |
Note what updateAge does not do: it re-issues the cookie so a busy user's session does not lapse mid-session, but it does not shorten the exposure window you set with maxAge. Set maxAge to your real tolerance for a stolen cookie, not to the number that minimizes support tickets.
One more trap follows from putting claims in the cookie: they go stale. A role, plan tier, or name baked into a JWT at sign-in stays there until refresh, so a demotion is invisible to anything that trusts the token. For decisions that must be immediate, read the fact from the database after verifying the token — signature first, fresh data second. The signing side of this is unchanged from Protecting Routes in Middleware: one AUTH_SECRET, verified the same way everywhere.
Where the Token Lives: HttpOnly Cookies, the 4 KB Ceiling, and What XSS Can Still Do
Verification and expiry are irrelevant if the wrong party can read the token. Storage is where the JWT-versus-session argument is actually decided, and browsers impose the hard constraint.
HttpOnly cookies keep tokens unreadable by scripts running in the page, which is the property that separates a cookie from anything you stash in JavaScript-accessible storage:
Set-Cookie: __Secure-authjs.session-token=eyJhbGci...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=604800
| Flag | Blocks | Without it |
|---|---|---|
HttpOnly |
document.cookie reads by injected script |
Any XSS payload exfiltrates the session in one line |
Secure |
Transmission over plain HTTP | Token sent in cleartext on an http visit |
SameSite=Lax |
Cross-site POST inclusion of the cookie | CSRF on state-changing endpoints |
Path=/ |
Scope mismatch across routes | Cookie withheld from the routes that need it |
__Host- prefix |
A parent domain setting a cookie over your app | Sibling subdomain can overwrite or read scope |
localStorage fails every row of that table. It is readable by any script, it is not sent automatically (you must attach it in JavaScript, which breaks every server component that wanted to read identity), and it never expires unless your code remembers to remove it. Teams adopt it because it is simple to setItem, and then discover the cost when a single injected third-party script walks off with a month of sessions. The same reasoning kills the common alternative of decoding the JWT into React state to render the header: you have re-created a client-side authority for data the server already had.
| Where the token lives | Readable by page scripts | Sent automatically | Expires on its own | Usable by Server Components |
|---|---|---|---|---|
HttpOnly cookie |
No | Yes | Yes (Max-Age) |
Yes |
localStorage |
Yes | No | No | No |
| In-memory JS variable | Yes | No | No (lost on reload) | No |
The SameSite choice has one escape hatch worth knowing: an app embedded in a cross-site iframe or consuming third-party cookies must use SameSite=None, which browsers accept only alongside Secure — drop either and the cookie is rejected outright rather than downgraded.
The cookie is not free, though — browsers drop any single cookie over roughly 4096 bytes, headers included. A JWT carrying roles, permissions, a full profile, and a shopping cart sails past that and the Set-Cookie is silently discarded. The symptom is brutal and confusing: sign-in succeeds, the redirect works, and the next request is anonymous, but only for users with long claims. Keep the payload to sub, email, and a compact role, or move the bulk to the server. That size ceiling is also why compact serialization (base64url, no spaces) is a format rule rather than a style preference.
Two residual risks deserve honest framing in an interview. HttpOnly stops reading, not acting: an XSS payload cannot copy the cookie, but it can issue authenticated requests from the page, so output encoding and a Content-Security-Policy remain mandatory. And SameSite=Lax covers top-level GET navigation but not every cross-site POST — Next.js Server Actions mitigate by comparing the Origin header against the Host, which is why a mismatched AUTH_URL behind a proxy can make actions fail with an origin error. Set the token cookie once, correctly, at the edge — the redirect logic in Protecting Routes in Middleware depends on it arriving — and the identity it carries becomes the input to Role-Based Access Control.
Quiz
1. You decode a JWT with atob and read role: "ADMIN" from the payload. Why is that not sufficient to authorize anything?
2. A token signed with HS256 using your AUTH_SECRET is submitted after someone edits role from USER to ADMIN. What does verification do?
3. Your app authorizes by reading role from the verified JWT, and an admin is demoted with six days left on exp. When does that token stop granting admin?
4. Sign-in succeeds, the redirect to /dashboard renders, and the very next request is anonymous — but only for users with large profile claims. Most likely cause?
Flashcards
Question
What does jwtVerify prove, and what does it not prove?
Click to reveal answer
Answer
It proves the header and payload were produced by someone holding AUTH_SECRET and that they have not been altered (and, with exp checked, that they are still within their validity window). It does not prove the claims are current — a role in a verified token can still be stale until the database agrees.
Question
Why pin algorithms: ["HS256"] in jwtVerify?
Click to reveal answer
Answer
The algorithm arrives in the token header and is attacker-controlled. Without a pin, alg: none (no signature) or an HMAC/RSA confusion trick can make verification accept a forged token. Pinning makes the header untrusted metadata and forces the one algorithm your key type supports.
Question
What do exp, iat, and nbf each bound?
Click to reveal answer
Answer
iat records issuance, exp is the instant after which the token must be rejected, and nbf is the instant before which it must be rejected. exp is what limits a stolen token, it is covered by the signature so it cannot be extended, and clockTolerance absorbs skew between edge nodes.
Question
How do you revoke a JWT before exp?
Click to reveal answer
Answer
You cannot do it from the token alone. Options: short access tokens with rotating refresh tokens (rotation invalidates the old one), a server-side denylist checked alongside verification, or falling back to a database session and deleting the row — which is the same guarantee with a different mechanism.
Question
Why store the JWT in an HttpOnly cookie instead of localStorage?
Click to reveal answer
Answer
HttpOnly keeps document.cookie and any injected script from reading the token; Secure stops plain-HTTP transmission and SameSite=Lax limits cross-site inclusion. localStorage is script-readable, never expires on its own, and is not sent automatically, so server components could not read identity without manual attachment.
Revision Notes
Key Takeaways
- 1. A JWT signature is checked with the secret before any claim inside it is trusted — the payload is signed, never encrypted
- 2. Pin algorithms to ["HS256"] and treat every verification failure identically as "no session" to keep permissive fallbacks out
- 3. The exp claim bounds how long a token remains acceptable, and because it is signed, only key rotation or a denylist shortens it
- 4. HttpOnly + Secure + SameSite cookies keep tokens out of page scripts and cross-site requests; localStorage fails every one of those properties
- 5. Cookie size is a silent failure mode: claims past ~4 KB cause the Set-Cookie to be dropped and sign-in to appear broken for some users
Interview Tips
- • "How do you validate a JWT?" — jose jwtVerify with the secret from env, algorithms pinned, clockTolerance small, and every error mapped to null; then add that decoding is for logs, never for authorization
- • "A user is demoted while their token is valid — when does access stop?" — at exp, and say the fix options in order of cost: shorter exp, denylist, or database session; admitting the limitation is the correct answer
- • "Why not store the token in localStorage?" — HttpOnly readability, Secure transmission, SameSite inclusion, automatic expiry, and server components needing the cookie on the request; then note XSS can still act even when it cannot read
- • "What breaks when AUTH_SECRET changes?" — every outstanding JWT fails verification at once, which is a feature for incident response; mention the coordinated rollout needed so sessions can be re-established cleanly
Cheat Sheet
Verifying JWTs on the Server
Verify before you trust:
import { jwtVerify, type JWTPayload } from "jose"
const secret = new TextEncoder().encode(process.env.AUTH_SECRET!)
export async function verifySessionToken(token?: string) {
if (!token) return null
try {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"], // pin: never let the token choose its algorithm
clockTolerance: 5, // seconds of allowed clock skew
})
return payload
} catch {
return null // expired, tampered, wrong secret → signed out
}
}
jwtDecode/atob is inspection only — the payload is signed, not encrypted, so it is attacker-controlled until verified.
Claims: sub (user), iat (issued), exp (hard stop), nbf/aud/iss (optional guards).
Lifetime config (JWT strategy):
session: { strategy: "jwt", maxAge: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 12 }
Revocation options: wait out exp · short token + rotating refresh · server-side denylist · database row delete.
Storage: Set-Cookie: ...; HttpOnly; Secure; SameSite=Lax; Path=/ — browser drops any single cookie over ~4096 bytes, so keep claims to sub + email + compact role.
Residual risk: HttpOnly blocks reading, not acting — XSS can still issue requests as the user, so output encoding and CSP remain required.