JWT Authentication: How It Works and Where It Fails
A clear-eyed guide to JWTs: structure, signing, verification, refresh flows, and the real-world failure modes nobody warns you about.
What you'll learn
- ✓Read a JWT and verify it correctly
- ✓Choose between HS256 and RS256
- ✓Implement access and refresh tokens in Node
- ✓Avoid the alg-none and key-confusion bugs
- ✓Decide when JWTs are the wrong tool
Prerequisites
- •Comfort with HTTP and JSON
- •Read [What is REST](/blog/what-is-rest)
- •Optional: [What is Node.js](/blog/what-is-nodejs)
A JWT is a signed, base64-encoded JSON object. That is the entire idea. The complications come from how you sign it, how you verify it, and how you handle expiry.
Anatomy
A JWT has three parts separated by dots: header, payload, signature.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MTIzIiwiZXhwIjoxNzE4NzAwMDAwfQ.abcdef...
Decode the first two with base64. The signature is computed over header.payload using the chosen algorithm and key.
const [h, p, s] = token.split(".");
const header = JSON.parse(Buffer.from(h, "base64url").toString());
const payload = JSON.parse(Buffer.from(p, "base64url").toString());
Decoding is not verifying. Anyone can decode; only the right key can verify.
Standard claims
iss: issuer.sub: subject, typically the user id.aud: audience, who the token is for.exp: expiry as Unix seconds.nbf: not before.iat: issued at.jti: unique token id, useful for revocation lists.
Always set iss, aud, and exp. Validate them on the server.
HS256 vs RS256
HS256 uses a shared secret. Fast, simple, and only safe when one party signs and verifies. RS256 uses a private key to sign and a public key to verify. Use RS256 when verifiers are distinct from issuers, like in microservices or OAuth2 setups.
Signing in Node
import jwt from "jsonwebtoken";
const token = jwt.sign(
{ sub: "u123", role: "admin" },
process.env.JWT_SECRET!,
{ algorithm: "HS256", issuer: "api.example.com", audience: "web.example.com", expiresIn: "15m" },
);
For RS256, pass a private key string and verify with the public key.
Verifying correctly
function verify(token: string) {
return jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ["HS256"],
issuer: "api.example.com",
audience: "web.example.com",
});
}
Two non-negotiables:
- Pass
algorithmsexplicitly. Never acceptnone. - Validate
issandaud. A token meant for service A must not authenticate service B.
Verifying with JWKS
For public-key setups, fetch the issuer JWKS and pick the key by kid.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://auth.example.com",
audience: "api.example.com",
});
Cache the JWKS but respect cache control. Key rotation requires verifiers to pick up new keys without redeploying.
Access and refresh tokens
Short-lived access tokens limit blast radius. Refresh tokens give the user a smooth experience without re-login.
function issueTokens(userId: string) {
const access = jwt.sign({ sub: userId }, ACCESS_SECRET, { expiresIn: "15m" });
const refresh = jwt.sign({ sub: userId, jti: crypto.randomUUID() }, REFRESH_SECRET, { expiresIn: "30d" });
return { access, refresh };
}
Store the refresh token jti in your database so you can revoke. Rotate the refresh token on every use; if you see the same jti twice, you have a replay and should kill the session.
Where to store tokens
- HTTP-only secure cookies for browsers: protected from XSS, vulnerable to CSRF without
SameSite. Authorizationheader for native apps and APIs.- Never
localStoragefor refresh tokens. Any script on the page can read them.
The classic vulnerabilities
- alg=none: old libraries accepted unsigned tokens. Pin algorithms.
- Key confusion: an RS256-verifying server tricked into accepting an HS256 token signed with the public key as a secret. Pin algorithms.
- Missing exp check: forever tokens leak forever. Always require
expto be present and in the future. - Trusting payload claims for authorization without revalidating. Roles can change; cache invalidation is your problem.
- Forgetting to validate
aud. A token for the support API should not pass on the billing API.
Revocation
JWTs are stateless, so revoking before expiry needs state somewhere. Options:
- Keep an allowlist of active jtis. Defeats stateless but works.
- Keep a denylist of revoked jtis. Fits short access-token windows.
- Use short expiry plus refresh-token rotation. The most common production pattern.
When JWTs are the wrong tool
If your app is a server-rendered monolith with one database, sessions are simpler and revocable in one line. JWTs earn their place when you have multiple services or a public auth server.
Wrap up
A JWT is a signed blob with claims. Verify the algorithm, the issuer, the audience, and the expiry. Rotate refresh tokens. Treat the secret like a database password. The protocol is small; the discipline is everything.