Senior 11 min · March 05, 2026

JWT Node.js — Why alg:none Still Bypasses Verification

jsonwebtoken accepts alg:none by default.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • JWT = JSON Web Token — stateless authentication where server signs a payload (user ID, roles, expiry); client sends token on each request
  • Key components: Header (algorithm, type), Payload (claims like sub, exp, roles), Signature (HMAC or RSA signed)
  • Performance: JWT verify takes 0.5-2ms (HS256) or 2-5ms (RS256) per request — scales horizontally without shared session storage
  • Production trap: Not checking expiry (exp claim) — token never expires, infinite session
  • Biggest mistake: Using alg: 'none' in production or not validating algorithm — attacker can forge admin tokens; always verify algorithm and never accept none
  • alg:none vulnerability: JWT libraries accept tokens with {"alg":"none"} header and empty signature. Attackers forge any user identity. Still exploitable in 2026 if algorithms not restricted.
Plain-English First

Imagine a theme park that gives you a wristband when you pay at the gate. Every ride operator just checks your wristband — nobody calls the ticket office to verify it each time. The wristband itself contains all the proof you need. JWT works exactly like that: the server stamps a token when you log in, and every protected route checks the stamp without touching a database. The magic is that the stamp is cryptographically unforgeable.

Session-based authentication used to be the default — store a session ID in a cookie, look it up in a database on every request, and return the user. That works fine for a single server, but the moment you scale horizontally across multiple Node.js instances or split your backend into microservices, you have a problem: which server holds the session? You either need a shared Redis store or sticky sessions, both of which add operational complexity and latency. JWT sidesteps this entirely by making the token itself the source of truth.

A JSON Web Token is a self-contained credential. It carries a payload — user ID, roles, expiry — cryptographically signed by the server. Any service that knows the secret (or the public key) can verify it instantly without a network round-trip. That is not just convenient; it is architecturally significant. It decouples authentication from state, which is exactly what stateless, distributed systems need.

By the end you will be able to build a complete JWT auth system in Node.js from scratch — issuing access tokens, rotating refresh tokens securely, protecting routes with middleware, handling token expiry gracefully, and avoiding the subtle security mistakes that show up in production code reviews. We will also cover algorithm choices, key management, and the honest trade-offs JWTs carry that most tutorials skip.

JWT Signing and Verification — The Algorithm Trap

JWT signing produces a token in three parts: header containing the algorithm and type, payload containing the claims, and a signature computed over the first two parts. The signature is what makes the token tamper-proof — change a single byte in the payload and the signature no longer matches.

When signing with jwt.sign(payload, secret, options), keep the payload minimal: userId, role, and nothing else the downstream service does not immediately need. Set expiresIn — omitting it produces a token that never expires, which is not a feature. Use a secret of at least 32 random characters for HS256; anything shorter is brute-forceable given enough time and a leaked token.

When verifying with jwt.verify(token, secret, options), three options are non-negotiable in production: - algorithms: ['HS256'] — explicit allowlist, never rely on the library default - clockTolerance: 60 — jsonwebtoken has no built-in tolerance; without this, a 2-second NTP drift causes intermittent 401s that are maddening to diagnose - ignoreExpiration: false — this is the default, but it has been overridden in production configs more times than you would expect, usually by someone copying a test helper

Catch TokenExpiredError and JsonWebTokenError separately. Return 401 for an expired token — the client should attempt a refresh. Return 403 for an invalid signature — that is not a retry situation, it is a rejection.

The algorithm confusion attack deserves a clear explanation because it is subtle. When you use RS256, your auth service signs with a private key and distributes the public key so other services can verify tokens. The public key is, by definition, not secret. An attacker who knows your public key can do the following: take a valid token, change the header algorithm from RS256 to HS256, re-sign the entire thing using the public key as the HMAC secret, and submit it. If your verification code trusts the algorithm declared in the header and does not restrict which algorithms it accepts, the server will attempt to verify an HS256 token using the public key as the HMAC secret — which is exactly what the attacker signed it with. Verification passes. The fix is always the same: specify algorithms explicitly in jwt.verify and never let the header dictate the algorithm.

One more thing most tutorials omit: use different secrets for access tokens and refresh tokens. If an attacker recovers your access token secret through a misconfigured environment variable or a memory leak, they should not also be able to forge refresh tokens. Two secrets, two separate environment variables, rotated independently.

io/thecodeforge/js/jwt_middleware.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
const jwt = require('jsonwebtoken');

// Production-grade JWT verification middleware for Express
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  // Explicitly specify allowed algorithms — never accept 'none'
  // clockTolerance handles NTP drift between issuing and validating services
  // jsonwebtoken has no built-in tolerance; without this, 2-second drift = intermittent 401s
  jwt.verify(
    token,
    process.env.JWT_SECRET,
    {
      algorithms: ['HS256'],       // allowlist — rejects alg:none and RS256 confusion attacks
      clockTolerance: 60,          // 60 seconds leeway for server clock drift
      ignoreExpiration: false      // default, but be explicit — test helpers override this
    },
    (err, decoded) => {
      if (err) {
        if (err.name === 'TokenExpiredError') {
          // Client should attempt refresh — not a hard rejection
          return res.status(401).json({
            error: 'Token expired',
            code: 'TOKEN_EXPIRED'
          });
        }
        if (err.name === 'JsonWebTokenError') {
          // Invalid signature or malformed token — do not retry
          return res.status(403).json({
            error: 'Invalid token',
            code: 'INVALID_TOKEN'
          });
        }
        return res.status(403).json({ error: err.message });
      }

      req.user = decoded;
      next();
    }
  );
}

// Issuing access and refresh tokens
// Two separate secrets — access token compromise does not leak refresh signing capability
function generateTokens(userId, role) {
  const accessToken = jwt.sign(
    { userId, role },
    process.env.JWT_SECRET,           // access token secret
    { expiresIn: '15m', algorithm: 'HS256' }
  );

  // Refresh token — long-lived, stored hashed in database or Redis
  const refreshToken = jwt.sign(
    { userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,   // different secret — rotate independently
    { expiresIn: '7d', algorithm: 'HS256' }
  );

  return { accessToken, refreshToken };
}
The alg:none Vulnerability — Still Working in 2026
The none algorithm was part of the original JWT specification for unsigned tokens in trusted environments. That use case almost never applies to a production API. The problem is that libraries still support it for backward compatibility, and if you do not explicitly restrict allowed algorithms, an attacker can send a token with {"alg":"none"} in the header, strip the signature entirely, and the library will accept it as valid. This was first widely exploited in 2015. It is still showing up in penetration test reports in 2026. One line fixes it: jwt.verify(token, secret, { algorithms: ['HS256'] }). There is no excuse for not having it.
Production Insight
jwt.sign takes roughly 0.5ms. jwt.verify runs synchronous crypto and blocks the event loop for 1-2ms on HS256, 3-5ms on RS256. At low to moderate request rates this is invisible. At sustained 5000+ requests per second it becomes measurable. If you hit that scale, offload verification to worker_threads — the pattern is well-established and the overhead is worth it. Until then, do not optimise prematurely; get the security right first.
Key Takeaway
jwt.sign creates a signed token. jwt.verify validates signature, expiry, and algorithm. Always pass algorithms: ['HS256'] to verify — the library default allows none and that is a critical vulnerability. Use separate secrets for access and refresh tokens. Keep payloads under 512 bytes.
JWT Algorithm Selection
IfSingle Node.js service — no other services validate the same tokens
UseUse HS256. One secret, stored in an environment variable, minimum 32 random characters. Rotate it quarterly and on any suspected compromise. Simple, fast, appropriate for the threat model.
IfMultiple microservices validate tokens issued by a central auth service
UseUse RS256. The auth service holds the private key and is the only service that can issue tokens. Every other service holds only the public key and can verify but never forge. A compromised downstream service cannot mint new tokens — the blast radius stays contained.
IfTokens must be validated by third-party services you do not control
UseUse RS256 with a JWKS endpoint. Publish public keys at /.well-known/jwks.json. Third parties fetch and cache the key themselves. You can rotate keys without coordinating with them — add the new key to the JWKS, let old tokens expire naturally, then remove the old key.
IfTokens are growing large (approaching 1KB payload) causing header size issues
UseSlim the payload first — you almost certainly do not need everything in there. If you genuinely need large per-request context, use reference tokens: a short opaque ID stored in Redis pointing to the full session object. You lose statelessness but gain size control.
IfYou need to revoke specific tokens before they expire
UseUse short-lived access tokens (15 minutes) plus refresh token rotation. For immediate revocation — password change, account lockout, suspected breach — maintain a Redis denylist keyed by the token's jti claim. Set TTL equal to remaining token lifetime so the denylist does not grow unboundedly.

Refresh Token Rotation — The Only Safe Way to Do Long-Lived Sessions

If you issue refresh tokens and never revoke them, they are permanent passwords. An attacker who intercepts one — via XSS, a compromised device, or a network log — can generate new access tokens for the full 7-day lifetime of the refresh token without ever re-authenticating. Refresh token rotation closes that window.

The principle is simple: each refresh request consumes the old token and produces a new one. The old token is deleted from the server-side store the moment it is used. If that same old token ever appears again, you know it was used by two parties — the legitimate user and someone who stole it. At that point you revoke everything for that user and force re-authentication.

The standard rotation flow: 1. Validate the incoming refresh token signature and expiry with jwt.verify. 2. Hash the raw token with SHA-256 and look it up in your store. If the hash is not there, the token was already rotated — trigger full revocation for that user. 3. Delete the old token hash from the store. This is the step most implementations skip, which means they are issuing new tokens without actually revoking old ones. 4. Generate a new access token and a new refresh token. 5. Hash the new refresh token and store it with a TTL matching the token's expiry. 6. Return the new pair to the client.

Why store a hash rather than the raw token? If your Redis instance or database is compromised, the attacker gets a list of hashes — not usable tokens. SHA-256 the token before storing it. The raw token only ever lives in memory during the rotation request and in the client's secure storage.

The TTL on stored hashes matters more than most people realise. If you store refresh token hashes without a TTL, every logout, every rotation, every expired token accumulates in your store forever. I have seen teams bring Redis to its knees with millions of orphaned token hashes that never expired. Match the hash TTL to the token expiry — 7 days for a 7-day refresh token. Redis will clean it up automatically.

One timing nuance worth understanding: if an attacker steals a refresh token at T=0, and the legitimate user refreshes at T=5 minutes, the attacker's copy becomes invalid at T=5. If the attacker tries to use the stolen token at T=3 — before the user refreshes — the attacker succeeds and the user's next refresh at T=5 will fail, triggering revocation. The theft window is between T=0 and whenever the legitimate user next refreshes. Short access token expiry (15 minutes) limits the damage during that window.

io/thecodeforge/js/refresh_token_rotation.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// In production: Redis with SETEX or a database table with expiry column
// This Map is illustrative — do not use in production
const refreshTokenStore = new Map(); // tokenHash -> { userId, expiresAt }

async function rotateRefreshToken(oldRefreshToken, userId) {
  // 1. Validate signature and expiry first
  let decoded;
  try {
    decoded = jwt.verify(oldRefreshToken, process.env.JWT_REFRESH_SECRET, {
      algorithms: ['HS256']
    });
    if (decoded.userId !== userId) {
      // Token is valid but belongs to a different user — reject hard
      throw new Error('Token userId mismatch');
    }
  } catch (err) {
    return { error: 'Invalid refresh token' };
  }

  // 2. Hash the raw token for store lookup — never store raw tokens
  const tokenHash = crypto
    .createHash('sha256')
    .update(oldRefreshToken)
    .digest('hex');

  const storedToken = await getRefreshTokenFromStore(tokenHash);
  if (!storedToken) {
    // Hash not found — token already rotated or revoked
    // This is the theft detection signal: two parties used the same token
    await revokeAllUserTokens(userId);
    return { error: 'Refresh token already used — session revoked', revokeAll: true };
  }

  // 3. Delete old token hash — one-time use, non-negotiable
  await deleteRefreshTokenFromStore(tokenHash);

  // 4. Generate new token pair
  const newAccessToken = jwt.sign(
    { userId, role: decoded.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m', algorithm: 'HS256' }
  );

  const newRefreshToken = jwt.sign(
    { userId, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d', algorithm: 'HS256' }
  );

  // 5. Store new hash — TTL must match token expiry or store grows unboundedly
  const newTokenHash = crypto
    .createHash('sha256')
    .update(newRefreshToken)
    .digest('hex');

  const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
  await storeRefreshToken(newTokenHash, userId, Date.now() + sevenDaysMs);

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

// Called when theft is detected (rotated token reused) or on password change
async function revokeAllUserTokens(userId) {
  // Remove all refresh tokens for this user from the store
  await db.refreshTokens.deleteMany({ userId });

  // Add userId to access token denylist for the remaining access token lifetime
  // TTL = 15 minutes (access token expiry) — Redis cleans this up automatically
  await redis.setex(`revoked_user:${userId}`, 15 * 60, 'true');
}
Refresh Token Rotation Pattern
The delete step is the rotation. Without deleting the old token from the store before issuing the new one, you are not rotating — you are just generating an additional token. Every refresh endpoint must: verify the incoming token, hash it, look it up, delete it, then generate and store the new one. If the lookup fails (hash not found), treat it as theft and revoke everything for that user. Store hashes, not raw tokens, so a compromised store does not hand an attacker a usable credential.
Production Insight
Refresh token rotation adds two store operations per refresh: one delete, one write. In Redis that is roughly 2-4ms of additional latency. This is entirely acceptable because refresh happens at most every 15 minutes per user, not on every API request. The access token verification path remains stateless and fast. The cost is paid infrequently; the security benefit is continuous.
Key Takeaway
Refresh token rotation means each token can be used exactly once. Delete the old token before issuing the new one — that is the rotation. If an already-rotated token is presented again, revoke all tokens for that user immediately. Store SHA-256 hashes of tokens, never the raw values. Set TTL on stored hashes to match token expiry or your store will grow without bound.

Secure Token Storage: Cookies vs localStorage — The XSS Trade-off

Where you store JWTs on the client determines your exposure profile. The decision is not a matter of preference — it has concrete security consequences that show up in penetration test reports and breach post-mortems.

localStorage is the path of least resistance. It is synchronous, simple to use, and works the same way everywhere. It is also readable by any JavaScript running on the page — your own code, third-party analytics scripts, chat widgets, A/B testing libraries, and anything injected via XSS. If an attacker finds an XSS vector in your application, they can exfiltrate every token from every active session in a single script tag. The token works from any machine until it expires. You will not know it happened until users start reporting unexpected account activity.

This is not theoretical. A startup I know of stored JWTs in localStorage. They integrated a third-party analytics script. That vendor's CDN was compromised. The injected script read tokens from localStorage across thousands of active sessions and replicated them to an attacker-controlled endpoint. The tokens were valid for 24 hours. By the time the breach was detected, the window was closed but the damage was done. Switching to HttpOnly cookies was a two-day rework that should have been the default from day one.

HttpOnly cookies cannot be read by JavaScript — the browser enforces this at the kernel level. XSS cannot exfiltrate what JavaScript cannot access. The trade-off is CSRF: cookies are sent automatically with every request to the matching domain, so a malicious site can trick the user's browser into making authenticated requests. Set SameSite=Strict to block cross-origin cookie submission entirely. This solves CSRF without needing separate anti-CSRF tokens for most use cases.

The SameSite=Strict limitation: if your API and frontend run on different origins — api.example.com and app.example.com — SameSite=Strict blocks the cookie. Use SameSite=Lax for navigation requests or SameSite=None; Secure for cross-origin API calls, paired with explicit CSRF token validation.

For single-page applications, the recommended pattern in 2026 is the Backend for Frontend (BFF). The SPA never handles the token directly. The BFF — a thin Node.js service that sits between the SPA and the API — receives the token from the auth service, sets it as an HttpOnly cookie, and proxies API requests. The SPA makes requests to the BFF; the BFF attaches the token from the cookie. The SPA never sees the token, which means XSS in the SPA cannot steal it.

For mobile applications, use platform-native secure storage: iOS Keychain and Android Keystore. Both are hardware-backed on modern devices. Do not store tokens in AsyncStorage or SharedPreferences — those are unencrypted.

io/thecodeforge/js/cookieAuth.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');

const app = express();
app.use(express.json());
app.use(cookieParser());

// Login: issue token as HttpOnly cookie, not in response body
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;

  const user = await authenticateUser(username, password);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = jwt.sign(
    { userId: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m', algorithm: 'HS256' }
  );

  // HttpOnly: JavaScript cannot read this — XSS cannot steal it
  // Secure: only transmitted over HTTPS
  // SameSite strict: browser will not send this cookie on cross-origin requests — CSRF blocked
  // maxAge matches token expiry — cookie clears itself when token expires
  res.cookie('access_token', accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production', // enforce HTTPS in production only
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000  // 15 minutes in milliseconds
  });

  // Return user info but never the token itself
  res.json({ message: 'Login successful', userId: user.id });
});

// Middleware: read token from cookie, not Authorization header
function cookieAuthMiddleware(req, res, next) {
  const token = req.cookies.access_token;

  if (!token) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  jwt.verify(
    token,
    process.env.JWT_SECRET,
    { algorithms: ['HS256'], clockTolerance: 60 },
    (err, decoded) => {
      if (err) {
        if (err.name === 'TokenExpiredError') {
          return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
        }
        return res.status(403).json({ error: 'Invalid session' });
      }
      req.user = decoded;
      next();
    }
  );
}

// Logout: clear the cookie server-side
app.post('/api/logout', (req, res) => {
  res.clearCookie('access_token', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  });
  res.json({ message: 'Logged out' });
});

app.get('/api/protected', cookieAuthMiddleware, (req, res) => {
  res.json({ user: req.user });
});
XSS + localStorage = Token Theft
If your application has an XSS vulnerability — even through a third-party script you did not write — tokens in localStorage are gone. The attacker reads them with two lines of JavaScript and replays them from any machine. HttpOnly cookies cannot be read by JavaScript at all. The browser enforces this. Use HttpOnly cookies with SameSite=Strict for web applications. If your API and frontend are on different origins, use SameSite=Lax with explicit CSRF validation rather than falling back to localStorage.
Production Insight
Switching from localStorage to HttpOnly cookies eliminates the XSS token theft vector entirely. The trade-off is CSRF, which SameSite=Strict handles for same-origin setups. Cross-origin setups require more thought — SameSite=None; Secure plus a custom CSRF header (X-Requested-With is sufficient for many cases) is the standard pattern. Most teams discover this trade-off after a penetration test, not before. Do not be that team.
Key Takeaway
localStorage is readable by any JavaScript on the page — XSS can steal tokens. HttpOnly cookies are not accessible to JavaScript at all. Use HttpOnly cookies with SameSite=Strict for web applications. For SPAs on a different origin than the API, use the BFF pattern. For mobile, use platform-native secure storage. Never store JWTs in localStorage in production.

JWKS Endpoint and Public Key Distribution for RS256

When you move to RS256, you immediately face a distribution problem: every service that validates tokens needs the public key. Hardcoding it into each service is the obvious first move and it works exactly until you need to rotate the key. Then you are coordinating a redeployment across every service simultaneously, hoping nothing drifts out of sync.

JWKS (JSON Web Key Set, RFC 7517) solves this. The auth service publishes its public keys at a standard endpoint — /.well-known/jwks.json — in a standardised format. Validation services fetch that endpoint on startup, cache the response, and use the cached keys for verification. Key rotation becomes: add the new key to the JWKS, wait for all tokens signed with the old key to expire, then remove the old key. No coordinated redeployment. No downtime.

Each key in the JWKS has a kid (key ID) field. The JWT header also carries a kid field identifying which key was used to sign it. During verification, the validator reads the kid from the token header, finds the matching key in the cached JWKS, and verifies the signature. This is how you can have two keys active simultaneously during a rotation — tokens signed with either key are validated correctly.

Implementation steps: 1. Generate an RSA key pair with crypto.generateKeyPairSync (2048-bit minimum; 4096-bit if the tokens are long-lived). 2. Compute the kid as SHA-256 of the public key PEM — this gives you a stable, deterministic identifier. 3. Expose /.well-known/jwks.json returning the public key serialised as a JWK object. 4. Sign tokens with the private key, including the kid in the JWT header. 5. In validation services, fetch the JWKS on startup and cache it in memory. Refresh the cache in the background every hour — not on every request. 6. During verification, decode the token header to extract the kid, look up the matching JWK, convert it to PEM, and verify.

The caching gotcha: if you cache the JWKS with a very long TTL and the auth service rotates keys, validation services will reject new tokens until the cache expires. Short TTL (5-15 minutes) for the cache refresh interval is the right balance — it means at most 15 minutes of lag when a new key is published, without fetching the JWKS on every request.

The missing require gotcha: production JWKS implementations depend on a JWK-to-PEM conversion library. Do not write your own RSA key parser. Use jwks-rsa (for consumers) or node-jose (for both producers and consumers). The code below shows the structural pattern; reference the library for the actual conversion functions.

Never put private keys in the JWKS. This sounds obvious, but misconfigured key serialisation has leaked private keys through JWKS endpoints in real production systems. Audit your endpoint response before deploying.

io/thecodeforge/js/jwks.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
const crypto = require('crypto');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa'); // npm install jwks-rsa

// --- Auth Service: publish JWKS and sign tokens ---

// Generate RSA key pair — do this once, store keys securely (not in code)
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

// Stable kid: SHA-256 fingerprint of the public key PEM
const kid = crypto.createHash('sha256').update(publicKey).digest('hex');

const authApp = express();

// JWKS endpoint — public keys only, never private
// Validation services fetch and cache this; no auth required on this endpoint
authApp.get('/.well-known/jwks.json', (req, res) => {
  // node-jose or manual JWK construction
  // Shown here as the structure — use a library for real RSA parameter extraction
  const jwk = {
    kty: 'RSA',
    use: 'sig',
    alg: 'RS256',
    kid: kid,
    // n and e are the RSA public key parameters in Base64Url encoding
    // Use node-jose: JWK.asKey(publicKey, 'pem').then(k => k.toJSON())
    n: '<base64url-encoded-modulus>',
    e: 'AQAB'
  };

  res.json({ keys: [jwk] });
});

// Sign tokens with private key — include kid in header
function signJwt(userId, role) {
  return jwt.sign(
    { userId, role },
    privateKey,
    {
      algorithm: 'RS256',
      keyid: kid,         // included in JWT header — validators use this to pick the right key
      expiresIn: '15m'
    }
  );
}

// --- Validation Service: fetch JWKS and verify tokens ---

// jwks-rsa handles caching, rotation, and rate limiting automatically
const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  cache: true,
  cacheMaxEntries: 5,
  cacheMaxAge: 10 * 60 * 1000  // 10 minute cache — short enough to pick up rotated keys
});

async function verifyJwt(token) {
  // Decode header first to extract kid — no verification at this stage
  const decoded = jwt.decode(token, { complete: true });
  if (!decoded || !decoded.header.kid) {
    throw new Error('Token missing kid — cannot select verification key');
  }

  // Fetch matching public key from cache (fetches from JWKS endpoint if cache miss)
  const key = await client.getSigningKey(decoded.header.kid);
  const publicKey = key.getPublicKey();

  // Verify with explicit algorithm — never trust the header's algorithm declaration
  return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
}
JWKS Caching Advice
Cache the JWKS response in memory. Do not fetch it on every token verification — that defeats the stateless performance advantage entirely and makes your JWKS endpoint a single point of failure for every API request. Use jwks-rsa, which handles caching, stale-while-revalidate refresh, and rate limiting out of the box. Set cacheMaxAge to 5-10 minutes — short enough to pick up rotated keys promptly, long enough to not hammer the auth service.
Production Insight
JWKS enables zero-downtime key rotation because you can have two keys active simultaneously. Add the new key, deploy the auth service to start signing with it, wait for all tokens signed with the old key to expire (maximum 15 minutes if your access token TTL is 15m), then remove the old key from the JWKS. Validation services pick up the change automatically on their next cache refresh. Without JWKS, key rotation is a coordinated multi-service deployment that almost always has a gap where some service rejects valid tokens.
Key Takeaway
JWKS standardises public key distribution for RS256. The auth service publishes public keys; validation services fetch and cache them. The kid field in the JWT header identifies which key to use, enabling zero-downtime key rotation. Use jwks-rsa for the consumer side — it handles caching and rotation correctly. Cache keys locally with a 5-10 minute TTL. Rotate keys quarterly and immediately on any suspected private key compromise.

Blacklisting and Logout — The Stateless Trade-off

JWT's stateless nature is its primary architectural advantage. It is also the thing that makes logout harder than it should be. When a user logs out of a session-based system, you delete the session and the user is instantly deauthenticated. With JWTs, deleting the client-side token is cosmetic — the token itself is still cryptographically valid until its exp claim passes.

For most applications, this is fine. Short-lived access tokens (15 minutes) mean the maximum exposure window after logout is 15 minutes. The user has logged out. Their token will expire. If someone intercepts that token in the 15-minute window, they have a limited window to abuse it. This is an acceptable trade-off for the vast majority of use cases.

For some use cases, it is not acceptable. A user reports a compromised device. A password change triggered by a suspected breach. An administrator locking an account. These all require immediate token invalidation, and short TTLs alone do not provide that.

The denylist pattern handles this. Assign each JWT a jti (JWT ID) claim — a unique identifier generated at sign time. When you need to revoke a token, store the jti in Redis with a TTL equal to the token's remaining lifetime. During verification, after validating the signature and expiry, check whether the jti is in Redis. If it is, reject the request with a 401.

Two things will go wrong if you implement this carelessly. First: if you forget to set a TTL on the Redis key, the denylist grows without bound. Every revoked token adds a key that never expires. I have seen this bring down a Redis instance with 40 million keys that accumulated over six months. Always set the TTL to the remaining seconds until token expiry. Second: the denylist check must happen after signature verification, not before. If you check the denylist first with a jwt.decode (no verification), an attacker can craft a token with a jti that is not on the denylist and bypass it. Verify the signature first. Then check the denylist.

The performance cost of a denylist is real: one Redis round trip per authenticated request, adding 1-3ms. For most applications this is fine. For high-frequency internal service calls, consider whether you actually need instant revocation or whether short TTLs are sufficient. Be deliberate about the trade-off rather than adding the denylist by default.

io/thecodeforge/js/blacklist.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { createClient } = require('redis');

const redis = createClient({ url: process.env.REDIS_URL });

// Revoke an access token — store jti with TTL = remaining token lifetime
// Only call this after jwt.verify has confirmed the token is valid
async function revokeAccessToken(accessToken) {
  const decoded = jwt.decode(accessToken);
  if (!decoded || !decoded.jti || !decoded.exp) {
    throw new Error('Token missing jti or exp — cannot revoke');
  }

  const remainingSeconds = decoded.exp - Math.floor(Date.now() / 1000);
  if (remainingSeconds <= 0) {
    return; // already expired — nothing to revoke
  }

  // TTL must match remaining lifetime — never store without TTL
  // Missing TTL = denylist grows unboundedly = eventual Redis OOM
  await redis.setEx(`blacklist:${decoded.jti}`, remainingSeconds, 'revoked');
}

// Denylist check middleware — runs AFTER signature verification
// Do not run this before verifying the signature — decode alone can be spoofed
async function checkBlacklist(req, res, next) {
  const token = req.headers['authorization']?.split(' ')[1];
  if (!token) return next();

  // req.user is set by the JWT verification middleware that runs before this one
  // We use req.user.jti rather than decoding again — signature already verified
  const jti = req.user?.jti;
  if (!jti) return next(); // token has no jti — cannot check denylist

  const isRevoked = await redis.exists(`blacklist:${jti}`);
  if (isRevoked) {
    return res.status(401).json({ error: 'Token revoked', code: 'TOKEN_REVOKED' });
  }

  next();
}

// Logout: clear refresh token from store + optionally revoke access token
async function logout(req, res) {
  const userId = req.user.userId;
  const refreshToken = req.body.refreshToken;

  // Always: delete refresh token from store
  if (refreshToken) {
    const tokenHash = crypto
      .createHash('sha256')
      .update(refreshToken)
      .digest('hex');
    await deleteRefreshTokenFromStore(tokenHash);
  }

  // Optional: revoke access token for immediate invalidation
  // Adds Redis lookup per request — only worth it if instant revocation is required
  const accessToken = req.headers['authorization']?.split(' ')[1];
  if (accessToken) {
    await revokeAccessToken(accessToken);
  }

  res.json({ message: 'Logged out successfully' });
}

// Sign tokens with jti for revocation support
function generateAccessToken(userId, role) {
  return jwt.sign(
    {
      userId,
      role,
      jti: crypto.randomUUID() // unique token ID — required for denylist
    },
    process.env.JWT_SECRET,
    { expiresIn: '15m', algorithm: 'HS256' }
  );
}
Stateless vs Stateful — The JWT Trade-off
  • Without denylist: JWT is valid until expiry. Logout clears the client-side token only. A stolen token works until exp. Maximum exposure window = access token TTL (15 minutes if set correctly).
  • With denylist: add Redis lookup per request (1-3ms). Enables instant revocation. Introduces a stateful dependency — Redis becomes a required component in your auth path.
  • Reference tokens: store the full session in Redis, return a random opaque ID to the client. Fully revocable, completely stateful. You lose every stateless benefit JWT provides.
  • Short-lived access tokens (15 minutes) plus refresh token rotation is the right default for most applications. Maximum exposure after logout or token theft is 15 minutes. Simple to reason about, no additional infrastructure.
  • For financial systems, healthcare, or any context where immediate revocation is a compliance requirement: implement the denylist. Set TTLs correctly. Test that revoked tokens are rejected before the TTL expires. For everything else, short TTLs are sufficient.
Production Insight
The denylist adds one Redis round trip to every authenticated request. At 1-3ms per call and 1000 requests per second, that is 1-3 seconds of accumulated latency per second across your fleet — not trivial. Before adding a denylist, ask whether 15-minute access token expiry actually meets your security requirement. For most logout flows, it does. Reserve the denylist for password changes, account lockouts, and confirmed compromises where the 15-minute window is genuinely unacceptable.
Key Takeaway
JWT is stateless — the server does not track tokens. Logout without a denylist relies on token expiry. Short-lived access tokens (15 minutes) make this tolerable for most use cases. For immediate revocation, use a Redis denylist keyed by jti claim with TTL set to remaining token lifetime. Always set the TTL — a denylist without TTL grows without bound and will exhaust memory. Check the denylist after signature verification, never before.
● Production incidentPOST-MORTEMseverity: high

The alg:none Attack That Compromised Admin Accounts

Symptom
API logs showed requests with structurally odd tokens being accepted as valid. Users began reporting they could see other users' data. Audit logs showed administrator-level actions with no corresponding login event preceding them. No signature verification errors appeared anywhere in the logs — because there was no signature to verify. The team eventually noticed that manually corrupting a single character in the token's third segment still passed validation. That was the moment someone realised the signature segment was being ignored entirely.
Assumption
The team assumed jwt.verify() cross-checked the algorithm in the token header against whatever algorithm the server expected. They had no idea the library's default behaviour — if you pass a plain secret string without an explicit algorithms option — is to permit any algorithm the header declares, including none. Nobody had tested what happened when you sent a token with alg set to none. It was not in the test suite. It had never come up in code review.
Root cause
The verification code was a single line: jwt.verify(token, secret). No algorithm restriction. The jsonwebtoken library, for historical backward-compatibility reasons, includes none in its default allowed algorithm set when no explicit list is provided. An attacker discovered this, crafted a token with header {"alg":"none","typ":"JWT"}, set the payload to {"sub":"1","role":"administrator"}, appended an empty signature segment, and sent it. The library saw alg:none in the header, skipped cryptographic verification entirely, decoded the payload, and returned it as trusted. Every subsequent request with that forged token was treated as a legitimate administrator session. The breach ran for 72 hours before an anomaly in token size distribution triggered an alert.
Fix
1. Changed every verification call to explicitly restrict algorithms: jwt.verify(token, secret, { algorithms: ['HS256'] }). 2. Added a pre-parse check that rejects any token whose decoded header contains alg: none before the verify call even runs. 3. Rotated all signing secrets immediately and forced a global logout, requiring all users to re-authenticate. 4. Added an integration test that sends a crafted alg:none token to every authenticated endpoint and asserts a 403 response — this test now runs on every CI build. 5. Added a log-based alert that fires within 60 seconds whenever a request arrives carrying a token with an unrecognised or none algorithm value.
Key lesson
  • Never rely on library defaults for JWT algorithm validation. jsonwebtoken's defaults exist for backward compatibility, not for production security. Explicitly specify allowed algorithms on every single verify call — and never include none.
  • JWT verification is not set and forget. Your test suite must include adversarial token cases: alg:none, empty signature, expired timestamps set one second in the past, tampered payload with valid signature. If those tests do not exist, you do not know what your system actually does.
  • Monitor token structure in production. A token arriving with alg:none is not a misconfiguration — it is an active attack. Alert on it immediately, not in the next morning's log review.
  • For microservices, use RS256. The validation service only needs the public key. A compromised validation service cannot forge new tokens because it never had the private key. That asymmetry matters when your blast radius is twelve services instead of one.
Production debug guideSymptom to action mapping for common JWT failures in production Node.js APIs.5 entries
Symptom · 01
401 Unauthorized for requests that should be authenticated — but only sometimes, not consistently
Fix
This is almost always a clock drift problem. jsonwebtoken has no built-in clock tolerance — it does not silently forgive a token that expired two seconds ago. If your issuing service and your validating service run on different hosts and their system clocks diverge by more than a few seconds, tokens will appear expired the moment they are issued. Fix: add clockTolerance: 60 to every jwt.verify call. Then check NTP sync on all hosts with chronyc tracking or timedatectl. Also audit your codebase for multiple JWT libraries — jose and jsonwebtoken have different defaults and mixing them produces exactly this kind of intermittent failure.
Symptom · 02
User can access another user's data after changing the user ID in the JWT payload — manipulated token accepted
Fix
Signature verification is either absent or the algorithm restriction is missing, which means alg:none tokens are being accepted. Run grep -rn 'jwt.verify' src/ and check every call site for the algorithms option. If any call is missing it, that endpoint is exploitable. An attacker does not need to break the signature — they just set alg to none, remove the signature, and the library skips verification entirely. Fix every verify call to include algorithms: ['HS256'] or algorithms: ['RS256']. Then deploy the alg:none integration test described in the production incident above.
Symptom · 03
Refresh token rotation not working — old refresh token still valid after a new one is issued
Fix
You are issuing new refresh tokens but not deleting the old ones. Rotation without revocation is just token generation. On every refresh request: hash the incoming token with SHA-256, look it up in your store, delete it, then generate and store the new one. If you skip the delete step, an attacker who stole the original refresh token can keep using it in parallel with the legitimate user indefinitely. Check your refresh endpoint for a delete or revoke call before the new token is stored — if it is not there, it is not rotating.
Symptom · 04
Session not expiring — tokens accepted well after the expiry time you set
Fix
Two possible causes. First: the exp claim is missing from the token — jwt.sign was called without expiresIn, which produces a token that never expires. Decode a live token with jwt.decode(token) and inspect the exp field. If it is undefined, you found the bug. Second: ignoreExpiration is set to true somewhere in your verify options, probably copied from a test helper that leaked into production config. Search for ignoreExpiration: true across the codebase. Also verify that server clock is not running behind — a clock that is slow makes tokens appear younger than they are.
Symptom · 05
JWT payload too large — 413 Payload Too Large errors appearing from proxies or load balancers
Fix
JWTs travel in the Authorization header on every single request. Nginx defaults to an 8KB header buffer. HAProxy and AWS ALB have similar limits. If you are storing user profiles, full permissions lists, or nested objects in the JWT payload, you will hit these limits. Decode the payload of a production token and measure it: echo $TOKEN | cut -d '.' -f2 | base64 -d | wc -c. Anything over 1KB is a red flag. Fix: store only userId, role, and exp in the JWT. Fetch everything else from a Redis cache keyed by userId on each request. The cache hit adds 1-2ms, which is far cheaper than debugging header size limits across five different proxy configurations.
★ JWT Debug Cheat SheetFast diagnostics for JWT authentication issues in production Node.js applications.
Suspected alg:none attack — tokens with no signature being accepted
Immediate action
Check every jwt.verify call for explicit algorithm restriction
Commands
grep -rn 'jwt.verify' src/ | grep -v 'algorithms'
echo 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6ImFkbWluIn0.' | node -e " const jwt = require('jsonwebtoken'); const fs = require('fs'); const token = fs.readFileSync(0, 'utf-8').trim(); try { const result = jwt.verify(token, 'secret'); console.log('VULNERABLE: alg:none accepted, decoded:', JSON.stringify(result)); } catch(e) { console.log('Safe — token rejected:', e.message); } "
Fix now
Add algorithms: ['HS256'] to every jwt.verify call. Rotate all secrets immediately. Force global logout. Add the alg:none token to your CI integration test suite so this cannot regress silently.
Token expired but still accepted — no 401 returned+
Immediate action
Check whether exp claim exists and ignoreExpiration is set anywhere
Commands
grep -rn 'ignoreExpiration' src/
node -e "const jwt=require('jsonwebtoken'); console.log(JSON.stringify(jwt.decode(process.env.TOKEN), null, 2))" | jq '.exp // "MISSING — token never expires"'
Fix now
Ensure every jwt.sign call includes expiresIn: '15m'. Remove ignoreExpiration: true from all non-test code. Add clockTolerance: 60 to verify options to handle NTP drift without disabling expiry checking entirely.
401 on a valid token — invalid signature error in logs+
Immediate action
Check whether the secret or public key used for verification matches the one used for signing
Commands
echo $JWT_SECRET | sha256sum
grep -rn 'JWT_SECRET' .env src/config/
Fix now
Verify the same secret is used in both sign and verify. If using RS256, confirm the public key matches the private key pair with: openssl rsa -in private.pem -pubout | diff - public.pem. For multi-service environments, switch to a JWKS endpoint so key distribution is centralised and auditable.
Refresh token reuse detected — multiple successful refreshes with the same token+
Immediate action
Check whether old refresh tokens are actually deleted from the store on rotation
Commands
grep -rn 'refresh' src/ | grep -E 'delete|revoke|destroy|remove'
redis-cli KEYS 'refresh:*' | wc -l
Fix now
On every refresh: hash the incoming token, look it up in the store, delete it before generating the new one. If a previously rotated token appears again, treat it as theft — revoke all tokens for that user immediately and require re-authentication.
413 Payload Too Large — JWT in Authorization header exceeds proxy size limits+
Immediate action
Measure the actual payload size of tokens in production
Commands
echo $TOKEN | cut -d '.' -f2 | base64 -d | wc -c
grep -rn 'jwt.sign' src/ | grep -vE 'userId|role|exp'
Fix now
Strip the payload back to userId, role, and exp only. Move permissions lists, profile data, and nested objects to a Redis cache keyed by userId. Target under 512 bytes for the payload — that gives you headroom across every proxy and load balancer you will ever run behind.
JWT vs Session (Redis) vs OAuth 2.0 Tokens
AspectJWT (Stateless)Session (Redis/Database)OAuth 2.0 Opaque Token
Storage on serverNone — token is self-containedSession ID stored in Redis or databaseToken stored in database; client holds opaque reference
Scaling horizontallyTrivial — any instance validates the signature independentlyRequires shared session store (Redis) or sticky sessionsRequires token introspection endpoint or shared store
RevocationRequires denylist (adds state) or accept short TTL as the windowInstant — delete session from storeInstant — delete token from store or revoke via introspection endpoint
Performance per request0.5-2ms (HS256 crypto), 2-5ms (RS256) — no network call1-3ms Redis GET — fast but requires network round trip to store1-3ms for cached introspection; 20-50ms if introspection endpoint called live
LogoutClear client token; access token remains valid until expiry (up to 15m)Delete session — deauthenticated immediatelyDelete token from store — deauthenticated immediately
Payload sizeGrows with claims — keep under 512 bytes or header limits biteSession ID only (typically 32 bytes) — size is not a concernOpaque token is small; session data lives server-side
Best forMicroservices, stateless APIs, horizontally scaled SPAsTraditional server-rendered apps where instant revocation mattersThird-party delegated access, federated identity (Google, GitHub, Okta)
XSS protectionDepends entirely on storage choice — HttpOnly cookie is safe, localStorage is notHttpOnly session cookie by default — XSS cannot read itBearer token in Authorization header — XSS risk if stored in JS-accessible storage
CSRF protectionBearer token in Authorization header is not sent automatically — no CSRF riskCookie sent automatically — requires SameSite or anti-CSRF tokenBearer token in Authorization header — same as JWT, no CSRF risk

Key takeaways

1
JWT is stateless
the token contains all claims and is verified by signature alone. No database lookup per request. Any service with the secret or public key can validate it independently.
2
Always specify allowed algorithms in jwt.verify
algorithms: ['HS256']. The library default permits alg:none, which allows anyone to forge any token without knowing the secret. This was a known vulnerability in 2015 and is still showing up in penetration test reports in 2026.
3
Refresh token rotation means each refresh token can be used exactly once. Delete the old token from the store before issuing the new one
that is the rotation. If an already-rotated token appears again, revoke all tokens for that user immediately.
4
Store refresh tokens in HttpOnly cookies, not localStorage. HttpOnly cookies cannot be read by JavaScript
XSS cannot steal what it cannot access. For SPAs on different origins, use the Backend for Frontend pattern.
5
Keep JWT payload under 512 bytes. Store only userId, role, and exp. Large payloads hit proxy header limits in environment-specific ways that are painful to debug. Move everything else to a Redis cache.
6
Use RS256 for microservices. Each validation service holds only the public key and can verify but never forge tokens. A compromised service cannot mint new credentials. Use JWKS to distribute public keys and enable zero-downtime key rotation.

Common mistakes to avoid

5 patterns
×

Not specifying algorithms: ['HS256'] in jwt.verify — accepting alg:none

Symptom
Attackers send tokens with {"alg":"none"} in the header and an empty signature segment. The library skips cryptographic verification entirely. Any attacker can forge any payload — including administrator roles — without knowing the secret.
Fix
Add the algorithms option to every jwt.verify call: jwt.verify(token, secret, { algorithms: ['HS256'] }). If using RS256, specify algorithms: ['RS256']. Add an integration test that sends a crafted alg:none token to every authenticated endpoint and asserts a 403 response. Run it in CI on every build.
×

Storing refresh tokens in localStorage — XSS makes them permanent passwords

Symptom
An attacker exploits an XSS vulnerability — or a compromised third-party script — to read refresh tokens from localStorage. With the refresh token, they generate new access tokens indefinitely, long after the user has closed the browser. The session never truly ends.
Fix
Store refresh tokens in HttpOnly cookies. The browser enforces that JavaScript cannot read HttpOnly cookies — XSS cannot exfiltrate what it cannot access. Set Secure: true (HTTPS only) and SameSite: 'strict'. For SPAs on a different origin than the API, use the Backend for Frontend pattern and handle the cookie server-side.
×

Issuing new refresh tokens without deleting the old one — rotation without revocation

Symptom
A stolen refresh token remains valid indefinitely because the server never removes it. The attacker generates access tokens in parallel with the legitimate user. There is no detection mechanism and no expiry for the stolen token beyond its original 7-day window.
Fix
On every refresh request: hash the incoming token, look it up in the store, delete it, then generate and store the new one. The delete is the rotation. If you skip it, you are not rotating. If an already-deleted token is presented again, treat it as theft and immediately revoke all tokens for that user.
×

Storing large objects in the JWT payload — user profiles, full permission lists, nested objects

Symptom
413 Payload Too Large errors appear from Nginx, HAProxy, or AWS ALB. The JWT is sent in the Authorization header on every single request. Headers have size limits — Nginx defaults to 8KB total. Large payloads hit these limits and fail in ways that are environment-specific and hard to reproduce locally.
Fix
Store only userId, role, and exp in the JWT. Keep the payload under 512 bytes — this gives you headroom across every proxy configuration you will ever encounter. Move everything else to a Redis cache keyed by userId. The cache lookup adds 1-2ms and is far cheaper than debugging header size limits across multiple infrastructure layers.
×

Using HS256 across multiple microservices with a shared secret

Symptom
Every service that validates tokens must hold the shared secret. If a single service is compromised — a debug endpoint left open, credentials in a log file, a misconfigured environment variable — the attacker has the signing secret and can forge tokens that any service in the cluster will accept.
Fix
Switch to RS256. The auth service holds the private key and is the only service that can sign tokens. All other services hold only the public key. A compromised validation service can verify tokens but cannot create new ones. The blast radius of a service compromise is contained to that service, not the entire cluster.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is a JWT, and why would you choose it over server-side sessions?
Q02SENIOR
Explain the JWT structure — header, payload, signature — and walk throug...
Q03SENIOR
How do you securely implement refresh token rotation in a Node.js API?
Q04SENIOR
Why should you avoid storing JWT tokens in localStorage? What is the alt...
Q05SENIOR
Compare JWT with refresh token rotation versus Redis sessions for a micr...
Q01 of 05JUNIOR

What is a JWT, and why would you choose it over server-side sessions?

ANSWER
A JWT is a self-contained credential: a Base64Url-encoded header and payload, signed with a secret or private key to produce a signature. The server does not store anything — the token carries all the information needed to authenticate a request. You choose JWT over server-side sessions when you need to scale horizontally without a shared session store. With sessions, every server needs access to the same session database (usually Redis), which adds operational complexity and a network round trip per request. JWT verification is a local cryptographic operation — any server that knows the secret or public key can validate the token without a network call. The trade-off is that logout and revocation are harder: a JWT is valid until it expires, regardless of what happens on the client side.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What is the difference between HS256 and RS256 for JWT signing?
02
How do I revoke a JWT before it expires?
03
Why does jsonwebtoken accept alg:none by default?
🔥

That's Node.js. Mark it forged?

11 min read · try the examples if you haven't

Previous
Node.js with MongoDB
7 / 18 · Node.js
Next
Node.js Streams and Buffers