Node.js JWT Auth
โฑ๏ธ ~5-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ชช JWT = a signed hall pass. Any teacher can verify it โ no need to call the office.
The hall pass analogy
The security office (auth server)issues hall passes when you log in. Each pass has your name, your permission level, and a stamp date. The pass is signed with the principal's special wax seal.
Any teacher (microservice)can look at the seal and immediately know: "This is real. It hasn't been tampered with. It hasn't expired." They don't need to walk back to the office and ask โ the seal is self-verifying.
Expiry is stamped on the pass (the expclaim). After 15 minutes the pass is void โ no central lookup needed. That's why access tokens are short-lived and refresh tokens exist to quietly issue a new pass.
The refresh tokenis like a season pass locked in a vault (httpOnly cookie). You can't read it from JavaScript. When the hall pass expires, your browser silently shows the season pass to the issuing office and gets a fresh hall pass.
JWT structure at a glance
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c3JfNDIiLCJyb2xlIjoiYWRtaW4ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header โ algorithm + type. Base64Url encoded (not encrypted).
Payload โ claims (userId, role, iat, exp). Base64Url encoded โ readable by anyone, so never put secrets here.
Signature โ HMACSHA256(header + '.' + payload, SECRET). Changing one character of the payload invalidates this.
The key insight
JWTs are stateless. The server doesn't need a session store โ it just verifies the signature. This scales to many microservices with zero shared state. The trade-off: tokens can't be revoked before expiry (unless you add a blacklist in Redis or use short expiry + refresh rotation).
Interactive Sandbox
โ Move something, see it react instantly.JWT anatomy โ click a segment to decode
Pattern
๐ Sign Token
| 1 | import jwt from 'jsonwebtoken'; |
| 2 | ย |
| 3 | const SECRET = process.env.JWT_SECRET!; // HS256 โ shared secret |
| 4 | ย |
| 5 | // Sign an access token |
| 6 | function signToken(userId: string, role: string): string { |
| 7 | return jwt.sign( |
| 8 | { userId, role }, // payload โ pick only what middleware needs |
| 9 | SECRET, |
| 10 | { expiresIn: '15m' } // short-lived; refresh token handles longevity |
| 11 | ); |
| 12 | } |
| 13 | ย |
| 14 | // RS256 alternative (asymmetric โ preferred for microservices): |
| 15 | // jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '15m' }) |
| 16 | // Any service with the PUBLIC key can verify โ no secret sharing needed. |
| 17 | ย |
| 18 | // Payload hygiene: include userId + role only. |
| 19 | // Don't embed email, permissions list, or DB data โ token can't be revoked, |
| 20 | // so stale data lives until expiry. |
Challenge
Click each of the 5 JWT auth patterns and explore the code. Then click each JWT segment (header ยท payload ยท signature) to decode it. Use the walkthrough to see the key insight for each pattern.
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q: Why use short-lived access tokens and long-lived refresh tokens?
JWTs are stateless โ there's no server-side revocation without extra infrastructure. If an access token is stolen, it's valid until expiry. A short window (15 minutes) limits the damage. The refresh token (stored httpOnly, never exposed to JS) lives 7โ30 days and is checked against the DB on every use โ so it can be revoked by deleting the DB record. Short access token = small damage window. Long refresh token = user stays logged in. Revocable refresh = you can log users out.
| 1 | // Access token stolen โ attacker has 15 min max |
| 2 | // Refresh token stolen โ delete from DB โ next refresh attempt = 401 |
| 3 | // Refresh token rotation โ detect reuse (sign of theft) โ revoke family |
Q: Why hash refresh tokens before storing them in the DB?
If your DB is breached, raw refresh tokens are immediately usable to impersonate users. Storing a SHA-256 hash means a stolen DB gives the attacker a list of hashes โ they can't reverse these back into the tokens needed to call your API. Same principle as password hashing: never store secrets in recoverable form.
Q: Why 12 bcrypt salt rounds and not more?
The cost factor doubles the work with every increment. At 12 rounds on modern hardware, bcrypt takes ~300ms per hash โ slow enough to make a brute-force dictionary attack take years, fast enough that a user barely notices login latency. Going to 14 rounds doubles that to ~1.2 seconds. The right number depends on your server speed; OWASP recommends a target of ~300ms. Never go below 10 in production.
| 1 | // rounds=10 โ ~100ms (minimum for prod) |
| 2 | // rounds=12 โ ~300ms (good default) |
| 3 | // rounds=14 โ ~1200ms (high security, login latency noticeable) |
| 4 | // Brute force at rounds=12: 300ms ร 10^9 attempts = ~9,500 years |
The Deep Dive
โ Spec refs, engine internals, the minutiae.RS256 (asymmetric) vs HS256 (symmetric)
HS256 uses a single shared secret: whoever can sign a token can also verify it. In a monolith this is fine, but in a microservice architecture every service that needs to verify tokens must hold the secret โ now you have N places to rotate and leak it.
RS256 uses a private key to sign (held only by the auth server) and a public key to verify (safe to distribute to every microservice). A compromised microservice leaks the public key โ useless to an attacker. Only the auth server can mint tokens.
| 1 | // HS256: sign + verify use the same secret |
| 2 | jwt.sign(payload, process.env.JWT_SECRET!, { algorithm: 'HS256' }); |
| 3 | jwt.verify(token, process.env.JWT_SECRET!); |
| 4 | ย |
| 5 | // RS256: sign with private key, verify with public key |
| 6 | jwt.sign(payload, fs.readFileSync('private.pem'), { algorithm: 'RS256' }); |
| 7 | jwt.verify(token, fs.readFileSync('public.pem')); // safe to embed in any service |
Token blacklisting with Redis for pre-expiry logout
JWTs are stateless by design โ but sometimes you need instant revocation (logout, password change, account suspension). The pragmatic solution: store the JWT's jti(JWT ID) claim in Redis with a TTL matching the token's remaining lifetime. Your verify middleware checks Redis after signature validation.
| 1 | // On sign: add a unique jti claim |
| 2 | jwt.sign({ userId, role, jti: crypto.randomUUID() }, SECRET, { expiresIn: '15m' }); |
| 3 | ย |
| 4 | // On verify middleware: |
| 5 | const decoded = jwt.verify(token, SECRET) as JwtPayload & { jti: string }; |
| 6 | const blocked = await redis.get(`blacklist:${decoded.jti}`); |
| 7 | if (blocked) return res.status(401).json({ error: 'Token revoked' }); |
| 8 | ย |
| 9 | // On logout: |
| 10 | const remaining = decoded.exp * 1000 - Date.now(); |
| 11 | await redis.set(`blacklist:${decoded.jti}`, '1', 'PX', remaining); |
Refresh token rotation: detecting reuse attacks
When a refresh token is used, immediately mark it as consumed and issue a new one. If you ever see an already-consumed token being presented again, it means either the client has a bug or (more likely) the token was stolen and is being replayed. Revoke the entire token family.
| 1 | // Each refresh token has a familyId |
| 2 | // On reuse detection: revoke all tokens with that familyId |
| 3 | const family = await db.refreshToken.findFirst({ where: { hash, revokedAt: null } }); |
| 4 | if (!family) { |
| 5 | // Token was already used โ possible theft |
| 6 | await db.refreshToken.updateMany({ |
| 7 | where: { familyId: stolenFamilyId }, |
| 8 | data: { revokedAt: new Date() }, |
| 9 | }); |
| 10 | return res.status(401).json({ error: 'Token reuse detected. Please log in again.' }); |
| 11 | } |
Rate limiting login endpoints with express-rate-limit
Login endpoints are brute-force targets. Rate limit by IP at minimum; combine with account lockout after N failures for stronger protection.
| 1 | import rateLimit from 'express-rate-limit'; |
| 2 | ย |
| 3 | const loginLimiter = rateLimit({ |
| 4 | windowMs: 15 * 60 * 1000, // 15 minutes |
| 5 | max: 10, // max 10 login attempts per IP per window |
| 6 | message: { error: 'Too many login attempts โ try again in 15 minutes' }, |
| 7 | standardHeaders: true, // Return RateLimit-* headers |
| 8 | legacyHeaders: false, |
| 9 | }); |
| 10 | ย |
| 11 | app.post('/auth/login', loginLimiter, loginHandler); |
| 12 | ย |
| 13 | // Account lockout (DB-level, per username, not just IP): |
| 14 | if (user.failedAttempts >= 5) { |
| 15 | user.lockedUntil = new Date(Date.now() + 30 * 60 * 1000); |
| 16 | await db.user.save(user); |
| 17 | return res.status(429).json({ error: 'Account locked for 30 minutes' }); |
| 18 | } |
Secure cookie flags: httpOnly, Secure, SameSite=Strict
httpOnly: cookie is invisible to JavaScript (document.cookie returns nothing). Prevents XSS attacks from stealing the refresh token.
Secure: cookie is only sent over HTTPS. Never set without this in production โ otherwise the token travels in cleartext.
SameSite=Strict: cookie is not sent on cross-site requests. Prevents CSRF attacks where a malicious site triggers a request to your API using the victim's cookie.
| 1 | res.cookie('refresh_token', rawToken, { |
| 2 | httpOnly: true, // no JS access โ XSS-proof |
| 3 | secure: true, // HTTPS only |
| 4 | sameSite: 'strict', // no cross-site requests โ CSRF-proof |
| 5 | maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms |
| 6 | path: '/auth', // scoped to /auth routes only (extra defence) |
| 7 | }); |
Interview Questions
โ Real questions from real interviews โ with answers.HS256 uses one shared secret; RS256 uses a private/public keypair โ RS256 is required for microservices.
A DB breach gives attackers hashes, not usable tokens โ same principle as password hashing.
401 = unauthenticated (who are you?); 403 = unauthorized (I know who you are, but no).
Per-password random salt makes precomputed rainbow tables useless; embedding it removes the need for a separate salt column.
Store the JWT's jti claim in Redis with a TTL matching the token's remaining lifetime and check it in middleware.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.When should you use RS256 over HS256 for JWT signing?