A security skill for auditing existing JWT auth setups and implementing new ones correctly. Covers the full surface area: algorithm selection, token lifecycle, storage, and revocation.
Scope
Use this skill for:
- Implementing JWT auth from scratch (backend + frontend)
- Auditing an existing JWT setup for security issues
- Answering targeted questions about any one JWT concern (storage, rotation, revocation, etc.)
- Recommending patterns for specific stacks (Node/Express, Python/FastAPI, etc.)
1. Signing Algorithm: RS256 vs HS256
| HS256 | RS256 | |
|---|---|---|
| Type | Symmetric (shared secret) | Asymmetric (private/public key pair) |
| Verification | Any party with the secret can verify and sign | Public key verifies; only private key signs |
| Key distribution | Secret must be shared with every verifying service | Public key can be freely distributed (JWKS endpoint) |
| Best for | Single-service apps, internal services | Multi-service / microservice architectures |
| Key rotation | Must rotate secret across all services simultaneously | Rotate private key; publish new public key via JWKS |
Recommendation rules:
- Use RS256 when more than one service needs to verify tokens (e.g., a separate API gateway, a resource server).
- Use HS256 for simple single-service setups where the secret never leaves one process.
- Never use
alg: none. Reject tokens with unexpected algorithms server-side.
Server-side enforcement (Node example):
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // whitelist explicitly
2. Token Expiry Design
Access Token
- Keep short: 5–15 minutes is the standard. The shorter the window, the smaller the blast radius if a token is leaked.
- Encode only what's needed in the payload (user ID, roles, tenant). Avoid PII.
- Do not use access tokens for anything other than resource access.
Refresh Token
- Long-lived: 7–30 days depending on UX requirements.
- Must be opaque (random string, not a JWT) — store server-side so it can be revoked.
- Store only a hashed reference in the DB (bcrypt or SHA-256), not the raw value.
exp and iat claims
{
"sub": "user_123",
"iat": 1700000000,
"exp": 1700000900,
"jti": "uuid-v4-here"
}
- Always include
jti(JWT ID) — needed for revocation and replay protection. - Validate
iatto reject tokens issued in the future (clock skew tolerance: ≤30s).
3. Refresh Token Rotation
Pattern: Rotating refresh tokens (recommended)
Each time a refresh token is used, issue a new refresh token and invalidate the old one.
Client → POST /auth/refresh { refresh_token: "abc" }
Server:
1. Look up "abc" in DB (hashed match)
2. Verify it's not expired or revoked
3. Invalidate "abc"
4. Issue new access_token + refresh_token "xyz"
5. Return both to client
Client stores "xyz", discards "abc"
Replay / theft detection: If a refresh token is used after it's already been rotated (i.e., an attacker and a legitimate client both try to use the same token), revoke the entire token family — all refresh tokens issued in this login session. Force re-authentication.
Token families:
- Assign a
family_idwhen a user first logs in. - Every rotation stays in the same family.
- On replay detection, revoke by
family_id.
DB schema (minimal):
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
family_id UUID NOT NULL,
token_hash TEXT NOT NULL, -- SHA-256 of raw token
user_id UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
4. Storage: httpOnly Cookie vs localStorage
See references/storage-tradeoffs.md for a detailed breakdown. Summary:
| httpOnly Cookie | localStorage | |
|---|---|---|
| XSS exposure | ✅ Not accessible via JS | ❌ Fully exposed to XSS |
| CSRF exposure | ⚠️ Requires CSRF mitigation | ✅ Not sent automatically |
| Works cross-origin | ⚠️ Needs SameSite + CORS config |
✅ Easier |
| Mobile/native clients | ❌ Cookie jars vary | ✅ Simple |
Default recommendation: httpOnly cookies with SameSite=Strict or SameSite=Lax.
Cookie config (Node/Express):
res.cookie('access_token', token, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 15 * 60 * 1000 // 15 min
});
If using cookies, always add CSRF protection:
SameSite=Stricthandles most cases for same-origin flows.- For cross-origin, use the Double Submit Cookie or Synchronizer Token pattern.
When localStorage is acceptable:
- Native mobile apps (no browser cookie store)
- SPAs calling third-party APIs cross-origin, where cookies are impractical
- Always pair with a strict CSP to reduce XSS blast radius
5. Revocation Strategy
JWTs are stateless — once issued, they're valid until exp. Revocation requires server-side state.
Option A: Short expiry + blocklist (recommended default)
- Keep access tokens short (≤15 min). Most revocation needs are covered by natural expiry.
- Maintain a blocklist of
jtivalues for tokens that must be immediately invalidated (logout, password change, account compromise). - Blocklist only needs to live until the token's
exp. Use Redis with TTL:
// On logout / forced revocation:
await redis.set(`blocklist:${jti}`, '1', 'EX', tokenTTLSeconds);
// On every request:
const blocked = await redis.get(`blocklist:${jti}`);
if (blocked) return res.status(401).send('Token revoked');
Option B: Version counter (no Redis required)
- Store a
token_versionper user in DB. - Embed version in JWT payload.
- On verify, reject if JWT version < DB version.
- Increment
token_versionto invalidate all existing tokens for that user.
ALTER TABLE users ADD COLUMN token_version INT DEFAULT 0;
// JWT payload
{ sub: "user_123", ver: 5, exp: ... }
// On verify
const user = await db.users.findById(payload.sub);
if (payload.ver < user.token_version) throw new Error('Token invalidated');
Tradeoffs:
- Blocklist: fine-grained (per token), requires Redis
- Version counter: coarse-grained (all tokens for user), DB-only, simpler
Option C: Reference tokens
Issue opaque tokens; validate against DB on every request. Full revocation control but eliminates statelessness benefit. Only use when real-time revocation is non-negotiable (financial, healthcare).
Audit Checklist
When reviewing an existing JWT implementation, check:
- Algorithm explicitly whitelisted server-side (no
alg: none) - Access token expiry ≤ 15 minutes
- Refresh tokens stored as hash, not plaintext
- Refresh token rotation enabled
- Replay detection with family-level revocation
-
jtiincluded in all tokens - Storage: httpOnly cookie preferred; if localStorage, CSP in place
- CSRF protection if using cookies
- Revocation path exists for logout + account compromise
-
expandiatboth validated on verify - Secrets / private keys not hardcoded; loaded from env/secrets manager
- HTTPS enforced in production
Output Format
When producing recommendations or an audit report, use Markdown. Structure output as:
- Summary — what's in place, what's missing
- Issues Found (if auditing) — severity: Critical / High / Medium / Low
- Recommendations — concrete, implementable steps
- Code Snippets — for any non-trivial implementation detail
For implementation tasks, produce working code in the user's stack. If stack is unknown, default to Node.js + Express with TypeScript.
Reference Files
references/storage-tradeoffs.md— Deep dive on cookie vs localStorage with CSRF patternsreferences/stack-examples.md— Stack-specific implementation snippets (Node, Python, Go)