Skip to content
C Codeloom
Backend

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.

·4 min read · By Yash Kesharwani
Intermediate 9 min read

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 algorithms explicitly. Never accept none.
  • Validate iss and aud. 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.
  • Authorization header for native apps and APIs.
  • Never localStorage for 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 exp to 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.