Most JWT tutorials stop at jwt.sign() and jwt.verify(). That's about 20% of what you need, and the missing 80% is where the security actually lives.
What a token really is
A JWT is three base64url segments separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY5YjE4ZjJhIiwiaWF0IjoxNzUwMH0.4Xk2…
└──────── header ────────┘ └──────── payload ────────┘ └── signature ──┘Decode the first two and you get plain JSON:
{ "alg": "HS256", "typ": "JWT" }
{ "id": "69b18f2a", "role": "admin", "iat": 1750000000, "exp": 1750604800 }This is the single most important fact about JWTs: the payload is encoded, not encrypted. Anyone holding the token can read it — no key required, jwt.decode() or jwt.io will do it.
So never put anything in there you wouldn't print on a postcard. No passwords, no PII beyond an identifier, no internal flags you'd rather not expose.
What the signature gives you is integrity: proof the payload hasn't been altered, because producing a valid signature requires the secret. Change one character of the payload and verification fails.
Signing and verifying
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET;
if (!SECRET) throw new Error('JWT_SECRET is not set');
export function signToken(userId: string) {
return jwt.sign({ id: userId }, SECRET, {
expiresIn: '15m',
algorithm: 'HS256',
issuer: 'adityabhavar.me',
audience: 'adityabhavar.me',
});
}
export function verifyToken(token: string) {
return jwt.verify(token, SECRET, {
algorithms: ['HS256'], // ← pin this, see below
issuer: 'adityabhavar.me',
audience: 'adityabhavar.me',
}) as { id: string };
}That algorithms: ['HS256'] array is not decoration. Without it, verify trusts the alg field in the token itself — which the attacker controls. Historically that enabled two real attacks: setting alg: "none" to skip verification entirely, and switching an RS256 setup to HS256 so the public key gets used as an HMAC secret. Pin the algorithm and both disappear.
The secret matters too. your_jwt_secret_key_change_this is brute-forceable in seconds. Generate a real one:
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"Where the token lives
This decision matters more than anything about the token itself.
localStorage |
httpOnly cookie |
|
|---|---|---|
| Readable by JavaScript | yes | no |
| Survives XSS | no | yes |
| Sent automatically | no | yes |
| CSRF exposure | none | needs sameSite |
| Works cross-domain | easily | needs configuration |
If an attacker lands any XSS on your page, a token in localStorage is theirs. An httpOnly cookie can't be read by script at all.
import { cookies } from 'next/headers';
(await cookies()).set('token', signToken(user.id), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 15,
});sameSite: 'lax' means the cookie is sent on top-level navigation but not on cross-site form posts or subresource requests — which blocks the common CSRF shapes without breaking normal links. If you genuinely need sameSite: 'none', you also need explicit CSRF tokens.
Expiry, and the revocation problem
Here's the trade-off nobody mentions up front: a signed JWT is valid until it expires, and you cannot un-issue it.
If a user logs out, changes their password, or gets their access revoked, any token already in the wild keeps working. There's no server-side session to delete — that's the entire point of stateless auth, and it's also its main weakness.
Three ways to handle it:
Short expiry plus a refresh token. Access tokens live 15 minutes; a longer-lived refresh token mints new ones. Damage is bounded to one window. This is the standard answer.
A denylist. Store revoked token IDs in Redis with a TTL matching their remaining lifetime:
const payload = jwt.sign({ id: user.id, jti: crypto.randomUUID() }, SECRET, { expiresIn: '15m' });
// On logout
await redis.set(`revoked:${jti}`, '1', { EX: 900 });
// On verify
if (await redis.get(`revoked:${claims.jti}`)) throw new Error('Token revoked');Honest caveat: this reintroduces a lookup on every request, which is the statefulness you were trying to avoid. It's a reasonable trade when logout must be immediate.
A token version on the user record. Bump tokenVersion on password change and include it in the payload; reject tokens whose version is stale. Cheap if you're already loading the user.
The middleware
import { NextResponse, type NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function middleware(req: NextRequest) {
const token = req.cookies.get('token')?.value;
if (!token) return NextResponse.redirect(new URL('/login', req.url));
try {
const { payload } = await jwtVerify(token, secret, { algorithms: ['HS256'] });
const headers = new Headers(req.headers);
headers.set('x-user-id', String(payload.id));
return NextResponse.next({ request: { headers } });
} catch {
const res = NextResponse.redirect(new URL('/login', req.url));
res.cookies.delete('token'); // clear the bad cookie so it stops retrying
return res;
}
}
export const config = { matcher: ['/dashboard/:path*', '/api/protected/:path*'] };Note jose rather than jsonwebtoken — Next.js middleware runs on the Edge runtime, which has no Node crypto module. jose uses Web Crypto and works in both.
Mistakes I've made or reviewed
Putting the whole user object in the payload. Every request now carries a kilobyte of stale data. Store the ID; load the user when you need them.
Long-lived access tokens "for convenience." A 30-day access token is a 30-day breach window. 15 minutes plus a refresh token gives the same UX with a fraction of the exposure.
Trusting role from the token for sensitive operations. It was true when the token was minted. If you demoted the user five minutes ago, the token still says admin. For destructive actions, check the database.
Different errors for "user not found" and "wrong password." That turns your login form into a user-enumeration endpoint. One message for both.
No rate limit on login. Bcrypt is deliberately slow, which makes an unthrottled login endpoint a CPU exhaustion vector as well as a credential-stuffing target.
When not to use JWTs
For a normal server-rendered web app with one backend, session cookies backed by a store are simpler and safer. Revocation is a DELETE. There's no expiry-window trade-off. There's no algorithm confusion.
JWTs earn their complexity when you have multiple services that need to verify identity without sharing a session store, or a mobile client, or a third-party API. Reach for them because you have that problem — not because the tutorial did.
The mental model to keep: a JWT is a signed claim with an expiry date, not a session. Design around that and the rest follows.