JWT Node.js — Why alg:none Still Bypasses Verification
jsonwebtoken accepts alg:none by default.
- 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.
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.
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.
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.
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.
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.
- 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.
The alg:none Attack That Compromised Admin Accounts
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.- 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.
Key takeaways
Common mistakes to avoid
5 patternsNot specifying algorithms: ['HS256'] in jwt.verify — accepting alg:none
Storing refresh tokens in localStorage — XSS makes them permanent passwords
Issuing new refresh tokens without deleting the old one — rotation without revocation
Storing large objects in the JWT payload — user profiles, full permission lists, nested objects
Using HS256 across multiple microservices with a shared secret
Interview Questions on This Topic
What is a JWT, and why would you choose it over server-side sessions?
Frequently Asked Questions
That's Node.js. Mark it forged?
11 min read · try the examples if you haven't