Step 2
JWT · refresh · rotation
25 min
JWT · refresh · rotation
The practical standard for session management — provided you get the algorithm, expiry, and blacklist right.
1. JWT anatomy
<base64url header>.<base64url payload>.<base64url signature>
Payload is encoded, not encrypted. Do not put secrets there.
2. HS256 vs RS256
| Alg | Key | Use |
|---|---|---|
| HS256 | symmetric | single service |
| RS256 | key pair | microservices · OAuth |
Use HS256 + 32-byte secret for a single app.
3. Issue (jose)
import { SignJWT } from "jose";
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
async function issueToken(userId: string) {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(SECRET);
}
4. Short access + long refresh
access → 15 min
refresh → 30 days
Stolen access token, max 15 min window. Refresh stays in HTTP-only cookie.
async function refreshTokens(oldRefresh: string) {
const { payload } = await jwtVerify(oldRefresh, SECRET);
await db.query("INSERT INTO jwt_blacklist (jti, exp) VALUES ($1, $2)",
[payload.jti, payload.exp]);
return { access: await issueToken(payload.sub), refresh: await issueRefresh(payload.sub) };
}
5. Blacklist or whitelist
- Blacklist — store logged-out jtis. Smaller.
- Whitelist — store all issued jtis. Stricter.
Blacklist is usually smaller in practice.
6. HTTP-only cookie vs Authorization header
| Store | XSS | CSRF | Convenience |
|---|---|---|---|
| HTTP-only cookie | safe | at risk | auto for SSR |
| localStorage | unsafe | safe | needs CORS config |
Cookie + sameSite=lax + CSRF token is the practical combo.
7. Middleware
export async function requireAuth(req: Request) {
const token = req.headers.get("authorization")?.replace("Bearer ", "")
?? req.cookies?.get("access_token")?.value;
if (!token) throw new Error("unauthorized");
const { payload } = await jwtVerify(token, SECRET);
if (await isBlacklisted(payload.jti)) throw new Error("revoked");
return payload;
}
8. Gotchas
- Short secrets — 32 bytes minimum
- Secrets in payload — it is only encoding
- Too-long expiry — >1h access is risky
- Alg downgrade (
{"alg":"none"}) — whitelist algorithms - No TTL on blacklist → table bloat
9. Checklist
- 32+ byte secret
- access 15 min · refresh 30 days
- Rotate refresh, add old to blacklist
- Delete cookies on logout
- HS256 only (algorithm whitelist)
Closing
JWT is "convenient but risky without expiry and rotation". Short access + refresh covers the gap.
Next
- 03-oauth-state-pkce