jwt-backend
$npx skills add blunotech-dev/agents --skill jwt-backendImplement JWT auth on the backend, including token generation, validation, refresh rotation, revocation, and secure storage. Use when building token-based auth, securing endpoints, or handling expiry and access/refresh flows.
| name | description | category |
|---|---|---|
| jwt-backend | Implement JWT auth on the backend, including token generation, validation, refresh rotation, revocation, and secure storage. Use when building token-based auth, securing endpoints, or handling expiry and access/refresh flows. | Backend |
JWT Backend Skill
Phase 1 — Discovery
Ask only what context doesn't reveal:
- Single service or multiple services? HS256 (shared secret) works for single service. RS256/ES256 (asymmetric) is required when multiple services need to verify tokens independently — each service gets the public key only.
- What's the token consumer? Browser (cookie storage viable), mobile (secure storage), or service-to-service (in-memory)? Storage recommendation differs significantly.
- Is logout/revocation required? JWTs are stateless — if you need revocation, you need a denylist. If you don't want that complexity, you're accepting that tokens are valid until expiry.
- Framework/language? Middleware integration pattern differs across Express, FastAPI, Rails, Go chi, etc.
- Session duration expectations? Short-lived access tokens (5–15 min) + long-lived refresh tokens is the correct architecture. If they want a single long-lived token, that's a design smell to address.
Phase 2 — Architecture Decisions
Algorithm selection (the part teams get wrong first)
Never use alg: none — some libraries accepted unsigned tokens when none was passed. Always explicitly whitelist the expected algorithm in validation:
jwt.decode(token, secret, algorithms=["HS256"]) # explicit allowlist, never omit
HS256 vs RS256 vs ES256:
- HS256: single shared secret signs and verifies. If the secret leaks from any service, all tokens are forgeable. Use only when one service both issues and verifies.
- RS256: private key signs, public key verifies. Services only need the public key to verify — no secret exposure risk. Standard for any multi-service setup.
- ES256: same asymmetric model as RS256 but smaller tokens and faster verification. Preferred over RS256 for new systems.
Key size matters for HS256: The secret must be at least as long as the hash output — 256 bits (32 bytes) minimum for HS256. secret123 is not a valid JWT secret. Generate with openssl rand -base64 32.
Access + refresh token architecture
Access token: short-lived (5–15 minutes), stateless, validated without database lookup. Contains only what's needed for authorization decisions — user ID, roles, tenant ID. Nothing PII beyond what's required.
Refresh token: long-lived (7–30 days), opaque (not a JWT), stored server-side with a reference to the user. Used only to issue new access tokens. Must be rotated on every use.
Trap: making the refresh token a JWT. A JWT refresh token can't be invalidated without a denylist — you've gained nothing over a longer-lived access token.
Phase 3 — Implementation
Token generation — what to include and what not to
Required claims:
{
"sub": "user_123", // subject — stable user identifier
"iat": 1710000000, // issued at — for age checks
"exp": 1710000900, // expiry — always set; never omit
"jti": "uuid-v4-here" // JWT ID — required if you run a denylist
}
What not to put in the payload:
- Passwords, PII beyond user ID, full permission sets, anything you'd regret leaking
- Mutable data that might change before expiry (email, username) — it'll be stale during the token lifetime
- Roles that could be elevated — a token claiming
admin: trueissued today is still valid in 14 minutes even if the role was revoked
The nbf claim trap: nbf (not before) is rarely needed and creates clock skew issues in distributed systems. Omit unless you have a specific use case.
Validation middleware — the full check sequence
Validating signature alone is insufficient. A correct middleware validates in this order:
- Token present and well-formed — three base64url segments separated by dots
- Algorithm matches allowlist — reject if
algheader doesn't match expected - Signature valid — cryptographic verification
expnot passed — reject expired tokens (with a small clock skew tolerance: ≤30 seconds)nbfnot in the future — if claim is presentissmatches expected issuer — prevents tokens from one service being used at anotheraudmatches this service — prevents tokens intended for service A being replayed at service Bjtinot in denylist — only if revocation is implemented
// Express middleware skeleton
const validateToken = async (req, res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'missing_token' });
const token = header.slice(7);
try {
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // explicit allowlist
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
req.user = payload;
next();
} catch (err) {
// Distinguish error types for logging — don't expose details to client
if (err.name === 'TokenExpiredError') return res.status(401).json({ error: 'token_expired' });
return res.status(401).json({ error: 'invalid_token' });
}
};
Refresh token rotation
Rotation means: each refresh token use issues a new refresh token and invalidates the old one. This limits the window of a stolen refresh token.
Rotation with theft detection:
Store refresh tokens hashed in the database with a family identifier. If a refresh token that's already been rotated is presented (i.e., the old one after a new one was issued), invalidate the entire family — a token has been stolen and replayed.
CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
family_id uuid NOT NULL, -- all rotations of one lineage
token_hash text NOT NULL UNIQUE, -- store hash, never plaintext
user_id uuid NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz DEFAULT now()
);
Rotation flow:
- Client presents refresh token
- Look up by hash — if not found or
revoked_atset: deny, invalidate entire family (theft detected) - If valid: mark current token as revoked, issue new access token + new refresh token in the same family
- Return new pair atomically — if the response fails after the DB write, the client is locked out. Handle with a short grace window or idempotency key.
Revocation
The fundamental tradeoff: JWTs are designed to be stateless. Revocation reintroduces state. Choose one:
Option A — Accept non-revocability: Use very short access token expiry (5 min). On logout, delete the refresh token server-side. The access token remains valid until expiry — this window is acceptable for most apps.
Option B — Denylist (Redis): On logout or forced invalidation, add the jti to a Redis set with TTL matching the token's remaining lifetime. Middleware checks denylist on every request.
def revoke_token(jti: str, expires_at: datetime):
ttl = int((expires_at - datetime.utcnow()).total_seconds())
if ttl > 0:
redis.setex(f"revoked:{jti}", ttl, "1")
def is_revoked(jti: str) -> bool:
return redis.exists(f"revoked:{jti}") == 1
Trap: storing revoked JTIs in Postgres and querying on every request. This turns your stateless auth into a slow synchronous DB call per request. Redis (or Memcached) only for the denylist.
Option C — Short-lived tokens + token version on user: Add a token_version integer to the user record. Embed it in the JWT. On logout or force-revoke, increment token_version. Middleware rejects tokens where version < current. Requires one DB read per request — only acceptable if you already have a user cache.
Secure storage recommendations
Browser:
HttpOnly+Secure+SameSite=Strictcookies for both access and refresh tokens — notlocalStorage.localStorageis readable by any JavaScript on the page; a single XSS vulnerability exposes every token.- BFF (Backend for Frontend) pattern: the browser never sees the JWT. The BFF holds tokens server-side and issues session cookies to the browser.
- If you must use
Authorization: Bearerfrom JS, store in memory only (a closure or React state) — lost on page refresh, but XSS-safe.
Mobile:
- iOS: Keychain
- Android: EncryptedSharedPreferences or Android Keystore
- Never
AsyncStorageor unencrypted shared preferences — accessible on rooted devices
Service-to-service:
- Environment variable or secrets manager (Vault, AWS Secrets Manager) — never hardcoded, never in source control
- In-memory only at runtime; re-fetch from secrets manager on rotation
Clock skew and distributed validation
All services validating tokens must have synchronized clocks (NTP). A 5-minute clock drift can make valid tokens appear expired or not-yet-valid. Allow a small tolerance (leeway) in validation — 30 seconds is sufficient, 5 minutes defeats the purpose of short expiry.
Trap: deploying a token validation service in a container without NTP sync. Container clocks can drift from the host. Validate with timedatectl or date in CI.
Phase 4 — Output
Produce whichever the user needs:
- Token generation function — with correct claims, algorithm, and key loading
- Validation middleware — full claim check sequence for their framework
- Refresh token rotation implementation — DB schema + rotation logic + theft detection
- Revocation setup — Redis denylist with TTL-based cleanup
- Storage recommendation — specific guidance for their client type (browser/mobile/service)
- Algorithm migration plan — HS256 → RS256 for existing systems, with key rotation steps
Always include:
- Explicit algorithm allowlist in any validation code — never omit
issandaudclaim validation — not just signature- A note if they're using a single long-lived token (design smell, push toward access+refresh split)
- Warning if refresh token is a JWT (can't be truly revoked)
- Redis (not Postgres) for any denylist implementation