DevLift
Back to Blog

How JWT Works: Tokens, Claims, and Signatures

Take a JSON Web Token apart segment by segment, build a signer and verifier with nothing but node:crypto, then run the alg:none and RS256-to-HS256 key-confusion attacks against both your own code and jose@6.2.8 to see exactly which one still forges an admin token.

Admin
August 6, 202611 min read62 views
How JWT Works: Tokens, Claims, and Signatures

How JWT Works: Tokens, Claims, and Signatures

The first time I had to debug a JWT problem in production, the token in the logs looked like a cat had walked across a keyboard. Bearer eyJhbGciOi.... The auth library said "invalid signature" and stopped talking to me. I had no idea whether the bug was in the issuer, the verifier, the clock, or the key.

That is the real cost of treating sign() and verify() as a black box. Not elegance — debuggability. So this post takes the box apart: what the three segments are, how the signature is computed, what an attacker can and cannot change, and where the popular libraries still let you shoot yourself.

Everything below was run. The tokens, the byte counts, the timings and the attack results all come from a Node 22.22.3 session on Linux arm64 (4 vCPU), with jose@6.2.8 as the reference library. Where I cite a spec I am quoting the RFC text, not my memory of it.

Why we stopped looking sessions up

Stateful sessions are simple: you log in, the server stores a session record, your browser gets a session ID in a cookie, and every subsequent request is a lookup keyed on that ID.

It works until the lookup is the bottleneck. Ten services behind a load balancer, each hitting the session store on every request, and your identity database becomes the hottest path you own. You end up running a Redis cluster whose entire job is answering "who is this?" a hundred thousand times a second.

The stateless bet is different: put the identity in the request, and attach a proof that you issued it. No round trip. Verification becomes a local computation.

The catch — the part people skip — is that "no round trip" also means "no way to change your mind." More on that later.

Anatomy, with values you can reproduce

A JWT is three base64url segments joined by dots: header.payload.signature.

💡

JWTs are signed, not encrypted. Anyone holding the token can read the header and the payload — no key required. The signature proves the token has not been altered since it was issued. It hides nothing.

1

The header

Two fields: the media type and the signing algorithm.

{
  "alg": "HS256",
  "typ": "JWT"
}

HS256 is HMAC-SHA-256, a symmetric MAC. RS256 is RSASSA-PKCS1-v1_5 with SHA-256, asymmetric. RFC 7518 defines both, plus ES256, PS256 and a dozen others.

That JSON is base64url encoded. Base64url is ordinary Base64 with + replaced by -, / replaced by _, and the trailing = padding stripped, so the result survives a URL or an HTTP header untouched. Node has done this natively since v15 — buf.toString('base64url').

The encoded header, which you can check yourself with Buffer.from('{"alg":"HS256","typ":"JWT"}').toString('base64url'):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
2

The payload (claims)

The middle segment holds the claims — statements the token makes about its subject.

{
  "sub": "user_12345",
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com",
  "role": "admin",
  "iat": 1715000000,
  "exp": 1715003600
}

RFC 7519 sorts claim names into three buckets. Registered names are the ones the spec reserves and defines: iss, sub, aud, exp, nbf, iat, jti. Public names are the ones anybody may define, but §4.2 says they "should either be registered in the IANA "JSON Web Token Claims" registry ... or be a Public Name: a value that contains a Collision-Resistant Name." Private names are the rest — role, org_id, whatever your app needs, agreed bilaterally between issuer and consumer.

iat and exp are seconds since the Unix epoch, not milliseconds. One hour apart in the example above.

3

The signature

Concatenate the encoded header, a dot, and the encoded payload. That string is the signing input. Run it through the algorithm named in the header, using the key, and base64url the result.

HMAC_SHA256(
  base64url(header) + "." + base64url(payload),
  secret
)

The part worth internalising: the signature covers the header too. Changing alg from RS256 to HS256 invalidates the signature exactly as much as changing role from user to admin does. The attacks later in this post all work by getting the server to recompute the signature differently — never by leaving it alone.

⚠️

If you have seen SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c pasted around as "the signature", that is the jwt.io demo value. I recomputed it: it is the HMAC of the payload {"sub":"1234567890","name":"John Doe","iat":1516239022} under the secret your-256-bit-secret, and it corresponds to no other token. Signature strings are not decorative. Never copy one from an example into a different token.

Rendering diagram...

The Resource API never talks to the Auth Service. It needs the key and the expected issuer, nothing else.

Build one with nothing but node:crypto

Every TypeScript block from here on is one file, built up in order. The whole thing compiles clean under tsc --strict with no duplicate identifiers, and the tests pass.

import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto';
 
function b64urlEncode(input: Buffer | string): string {
  const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
  return buf.toString('base64url');
}
 
function b64urlDecode(input: string): Buffer {
  return Buffer.from(input, 'base64url');
}
 
// RFC 7518 §3.2: "A key of the same size as the hash output (for instance,
// 256 bits for "HS256") or larger MUST be used with this algorithm."
// A memorable passphrase is not a key. Generate bytes.
const SECRET: Buffer = randomBytes(32);
 
const ISSUER = 'https://auth.example.com';
const AUDIENCE = 'https://api.example.com';
 
interface Claims {
  sub: string;
  iss: string;
  aud: string;
  iat: number;
  exp: number;
  [key: string]: unknown;
}
 
function signJwtHs256(claims: Record<string, unknown>, ttlSeconds: number): string {
  const now = Math.floor(Date.now() / 1000);
  const header = { alg: 'HS256', typ: 'JWT' };
  const payload: Claims = {
    sub: String(claims.sub ?? ''),
    iss: ISSUER,
    aud: AUDIENCE,
    ...claims,
    iat: now,
    exp: now + ttlSeconds,
  };
 
  const signingInput =
    b64urlEncode(JSON.stringify(header)) + '.' + b64urlEncode(JSON.stringify(payload));
  const signature = createHmac('sha256', SECRET).update(signingInput).digest();
  return signingInput + '.' + b64urlEncode(signature);
}

signJwtHs256 sets exp itself rather than accepting one from the caller. A token whose lifetime is an optional argument eventually gets issued without one.

Now the verifier. Order matters here more than anything else in the file.

class JwtError extends Error {}
 
function verifyJwtHs256(token: string): Claims {
  const parts = token.split('.');
  if (parts.length !== 3) throw new JwtError('malformed token');
  const [encodedHeader, encodedPayload, encodedSignature] = parts;
 
  // 1. Signature first. Nothing in the token is trustworthy above this line.
  const expected = createHmac('sha256', SECRET)
    .update(encodedHeader + '.' + encodedPayload)
    .digest();
  const provided = b64urlDecode(encodedSignature);
  if (provided.length !== expected.length || !timingSafeEqual(expected, provided)) {
    throw new JwtError('signature mismatch');
  }
 
  // 2. Algorithm. RFC 8725 §3.1: the caller decides, not the token.
  const header = JSON.parse(b64urlDecode(encodedHeader).toString('utf8')) as Record<string, unknown>;
  if (header.alg !== 'HS256') throw new JwtError(`unexpected alg ${String(header.alg)}`);
  if (header.typ !== undefined && header.typ !== 'JWT') throw new JwtError('unexpected typ');
 
  const payload = JSON.parse(b64urlDecode(encodedPayload).toString('utf8')) as Record<string, unknown>;
 
  // 3. Claims. Each one is required, so a *missing* claim rejects.
  const now = Math.floor(Date.now() / 1000);
  if (typeof payload.exp !== 'number') throw new JwtError('missing exp');
  // RFC 7519 §4.1.4: exp is the time "on or after which the JWT MUST NOT be
  // accepted for processing", so now === exp is already too late.
  if (now >= payload.exp) throw new JwtError('token expired');
  if (typeof payload.nbf === 'number' && now < payload.nbf) throw new JwtError('token not yet valid');
  if (payload.iss !== ISSUER) throw new JwtError('wrong issuer');
  if (payload.aud !== AUDIENCE) throw new JwtError('wrong audience');
  if (typeof payload.sub !== 'string' || payload.sub.length === 0) throw new JwtError('missing sub');
 
  return payload as unknown as Claims;
}

Four decisions in there are load-bearing, and each is a bug I have watched someone ship:

  • timingSafeEqual, not !==. String comparison short-circuits on the first differing byte. Comparing a MAC that way leaks how many leading bytes a guess got right.
  • Signature before parsing. The header and payload are attacker-controlled strings until the MAC checks out. Parse them after, not before.
  • typeof payload.exp !== 'number', not if (payload.exp && ...). The truthy version accepts a token with no exp at all — it lives forever. That is the fail-open shape: the guard is skipped precisely when the data is missing. Make the missing case reject.
  • iss and aud are checked. RFC 8725 §3.9 is blunt: "if the audience value is not present or not associated with the recipient, it MUST reject the JWT." Without that check, an access token your billing service legitimately accepted can be replayed against your admin API.

Now attack it

Append this to the same file. assertOk throws — console.assert only logs, which means a completely broken verifier still prints a green line.

const assertOk = (cond: unknown, msg: string): void => { if (!cond) throw new Error('FAIL: ' + msg); };
 
function mustReject(fn: () => unknown, expected: string): void {
  try { fn(); } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    assertOk(message.includes(expected), `expected "${expected}", got "${message}"`);
    return;
  }
  throw new Error(`FAIL: expected a throw containing "${expected}", got a return`);
}
 
const nowSec = (): number => Math.floor(Date.now() / 1000);
const mint = (headerJson: string, payloadJson: string, key: Buffer): string => {
  const input = `${b64urlEncode(headerJson)}.${b64urlEncode(payloadJson)}`;
  return `${input}.${b64urlEncode(createHmac('sha256', key).update(input).digest())}`;
};
 
const goodToken = signJwtHs256({ sub: 'user_999', role: 'engineer' }, 3600);
assertOk(goodToken.split('.').length === 3, 'three segments');
assertOk(verifyJwtHs256(goodToken).sub === 'user_999', 'round trip');
 
const escalated = JSON.stringify({
  sub: 'user_999', role: 'admin', iss: ISSUER, aud: AUDIENCE,
  iat: nowSec(), exp: nowSec() + 3600,
});
 
// Swap the payload, keep the original signature.
const [origHeader, , origSig] = goodToken.split('.');
mustReject(() => verifyJwtHs256(`${origHeader}.${b64urlEncode(escalated)}.${origSig}`), 'signature mismatch');
 
// alg: none, signature stripped.
const noneHeader = JSON.stringify({ alg: 'none', typ: 'JWT' });
mustReject(() => verifyJwtHs256(`${b64urlEncode(noneHeader)}.${b64urlEncode(escalated)}.`), 'signature mismatch');
 
// alg: none, re-signed with a guessed all-zero key.
mustReject(() => verifyJwtHs256(mint(noneHeader, escalated, Buffer.alloc(32))), 'signature mismatch');
 
// alg: none, signed with the *real* key — caught by the algorithm check.
mustReject(() => verifyJwtHs256(mint(noneHeader, escalated, SECRET)), 'unexpected alg none');
 
// Correctly signed, but no exp: rejected instead of immortal.
const hs256Header = JSON.stringify({ alg: 'HS256', typ: 'JWT' });
const noExpiry = JSON.stringify({ sub: 'user_999', iss: ISSUER, aud: AUDIENCE });
mustReject(() => verifyJwtHs256(mint(hs256Header, noExpiry, SECRET)), 'missing exp');
 
// Correctly signed by us, but minted for a different API.
const otherAud = JSON.stringify({
  sub: 'user_999', iss: ISSUER, aud: 'https://billing.example.com',
  iat: nowSec(), exp: nowSec() + 3600,
});
mustReject(() => verifyJwtHs256(mint(hs256Header, otherAud, SECRET)), 'wrong audience');
 
mustReject(() => verifyJwtHs256('not.a.token'), 'signature mismatch');
mustReject(() => verifyJwtHs256('onlyonepart'), 'malformed token');
 
console.log('all checks passed');

Compiled with tsc --strict --target es2022 --module nodenext and run: all checks passed.

The alg: none cases are the interesting ones. Our verifier survives them for a reason that is easy to miss in code review: it never reads alg before deciding how to verify. It always computes HMAC-SHA-256 with the configured key, and only then asks whether the header agrees. Reverse those two steps and you have rewritten the 2015 bug.

The two attacks everybody name-drops, measured

RFC 8725 §2.1 lists both:

The algorithm can be changed to "none" by an attacker, and some libraries would trust this value and "validate" the JWT without checking any signature.

An "RS256" (RSA, 2048 bit) parameter value can be changed into "HS256" (HMAC, SHA-256), and some libraries would try to validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret.

A common retelling blames the spec for allowing none. That is not quite right, and the distinction matters. RFC 7518 §3.6 defined the unsecured JWS and in the same breath forbade accepting one by default: "Implementations MUST NOT accept Unsecured JWSs by default. In order to mitigate downgrade attacks, applications MUST NOT signal acceptance of Unsecured JWSs at a global level." RFC 8725 §3.2 goes further and says none "can be perfectly acceptable" when the token is already protected end-to-end by TLS. The 2015 vulnerabilities were libraries ignoring a MUST NOT, not a hole in the format. Worth being precise about, because the same class of bug — trusting an attacker-supplied field to pick your verification path — shows up far outside JWT.

So how does a current library behave? Here is the key-confusion attack run against jose@6.2.8. Same file, appended:

import { generateKeyPairSync } from 'node:crypto';
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
 
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const rsaPrivate = await importPKCS8(privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), 'RS256');
const rsaPublic = await importSPKI(pubPem, 'RS256');
 
const genuine = await new SignJWT({ sub: 'user_1', role: 'user' })
  .setProtectedHeader({ alg: 'RS256' })
  .setExpirationTime('1h')
  .sign(rsaPrivate);
 
// Forge: escalate the role, sign with HS256 using the PUBLIC pem as the secret.
const fHeader = b64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const fPayload = b64urlEncode(JSON.stringify({ sub: 'user_1', role: 'admin', exp: nowSec() + 3600 }));
const forged =
  `${fHeader}.${fPayload}.` +
  b64urlEncode(createHmac('sha256', pubPem).update(`${fHeader}.${fPayload}`).digest());
 
async function attempt(label: string, key: CryptoKey | Uint8Array, opts?: { algorithms: string[] }) {
  try {
    const { payload } = await jwtVerify(forged, key, opts);
    console.log(`${label} -> FORGERY ACCEPTED, role=${String(payload.role)}`);
  } catch (err) {
    const code = (err as { code?: string }).code;
    console.log(`${label} -> rejected: ${code ?? (err as Error).constructor.name}`);
  }
}
 
console.log('genuine token role:', String((await jwtVerify(genuine, rsaPublic)).payload.role));
await attempt('CryptoKey, no algorithms option ', rsaPublic);
await attempt('raw PEM bytes, no algorithms opt', new TextEncoder().encode(pubPem));
await attempt('raw PEM bytes, algorithms RS256 ', new TextEncoder().encode(pubPem), { algorithms: ['RS256'] });

Actual output:

genuine token role: user
CryptoKey, no algorithms option  -> rejected: TypeError
raw PEM bytes, no algorithms opt -> FORGERY ACCEPTED, role=admin
raw PEM bytes, algorithms RS256  -> rejected: ERR_JOSE_ALG_NOT_ALLOWED
🚨

The middle line is not a historical curiosity. On jose@6.2.8, today, a key-confusion forgery is accepted if you hand jwtVerify your public key as raw bytes and omit algorithms. What saves the first case is typing, not algorithm checking — a CryptoKey imported for RS256 cannot be used as an HMAC secret, so jose throws a TypeError before any crypto runs. Import your keys with importSPKI or createRemoteJWKSet, and pass algorithms anyway.

alg: none is a different story. Running the same forged claims with a none header through jwtVerify produces:

ERR_JOSE_NOT_SUPPORTED: alg none is not supported either by JOSE or your javascript runtime

jose has no none code path in its verifier at all. Unsecured JWTs live behind a separate, explicitly named UnsecuredJWT API, which is exactly the "explicitly requested by the caller" shape RFC 8725 §3.2 asks for. You cannot reach it by accident.

The rule that covers both, and that applies regardless of library:

// BAD: the token gets to choose how it is checked.
const untrustedAlg = JSON.parse(b64urlDecode(forged.split('.')[0]).toString()).alg;
await jwtVerify(forged, rsaPublic, { algorithms: [untrustedAlg] });
 
// GOOD: the caller decides up front, and the key is a typed key object.
await jwtVerify(genuine, rsaPublic, { algorithms: ['RS256'], issuer: ISSUER, audience: AUDIENCE });

RS256 and JWKS: why the big issuers went asymmetric

HS256 needs the same secret to sign and to verify. In a monolith that is fine. Across fifty services it means fifty copies of a key that can mint admin tokens, and the blast radius of one compromised service is the whole estate.

RS256 splits it. The auth service holds a private key and signs. Everyone else fetches a public key and can only verify.

Rendering diagram...

Not theoretical. I fetched two live discovery documents while writing this:

  • https://accounts.google.com/.well-known/openid-configuration reports "id_token_signing_alg_values_supported": ["RS256"] and points at https://www.googleapis.com/oauth2/v3/certs, which currently serves four RSA keys, each tagged "alg": "RS256", "use": "sig", with a distinct kid.
  • https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration likewise reports ["RS256"], with a jwks_uri of https://login.microsoftonline.com/common/discovery/v2.0/keys.

Multiple keys with distinct kid values is how rotation works: the issuer publishes a new key before it starts signing with it, and retires the old one once outstanding tokens have expired. Your verifier picks the key by kid — and per RFC 8725 §3.10, treats that kid as untrusted input, because it is a lookup value an attacker controls.

What it actually costs

The pitch for stateless auth is that verification is cheap. Here is what "cheap" measures at. A realistic six-claim payload, createHmac and crypto.verify from node:crypto, Node 22.22.3 on Linux arm64 (4 vCPU), timed with process.hrtime.bigint() after a warm-up call:

OperationIterationsPer op
HS256 verify200,0002.24 µs
RS256 verify (2048-bit)20,00019.2 µs
RS256 sign (2048-bit)2,000591 µs

Two things fall out. RS256 verification costs roughly 8.5x an HMAC but is still far under a network round trip to Redis, so the asymmetric setup is close to free on the request path. RS256 signing is 260x an HMAC verify — fine at login rates, a bad idea in a loop.

Size, same claims, same run: 175 bytes for the HS256 token, 474 bytes for the RS256 one. That 299-byte delta rides on every request, in every header, forever. It is not nothing on a mobile connection, and it is a real argument for keeping your claims small.

The two things people get wrong after this

"Logging out invalidates the token." It does not. Deleting a token from local storage removes the client's copy, not the attacker's. A signed, unexpired token stays valid until exp, because there is no lookup to consult. The usual answer is short-lived access tokens — minutes, not hours — plus a stateful refresh token you can revoke in a database. A Redis denylist works too, and the "but then you have reinvented sessions" objection is weaker than it sounds: a denylist holds one entry per revoked token rather than one per active session, and it is small enough to keep in memory. What it does cost you is a hard dependency on Redis being up during authorization, so decide in advance whether that path fails open or closed.

"Never put anything real in the payload." Too broad. The accurate rule is that the payload is readable by anyone holding the token, so it must contain nothing that would harm you if published — no secrets, no keys, nothing you would not put in a log line. Identity data itself is often the point: an OpenID Connect ID token routinely carries email, name and picture, by design, because it travels over TLS to a party that already knows who the user is. What to avoid is stuffing a whole user record into the payload to dodge a query, then discovering the data went stale five minutes into an hour-long token.

Check your understanding

An attacker decodes their JWT, changes 'role' from 'user' to 'admin', re-encodes the payload and sends it back. What happens at a correctly written verifier?

The signature is computed over the exact header.payload string. Change one byte of either and the recomputed MAC diverges. The attacker cannot produce a matching signature without the key — which is the entire point of the construction. The test block above proves it: the tampered token throws signature mismatch.

Why is HS256 a poor fit for a fifty-service architecture?

It is a key-distribution problem, not a strength problem. HMAC-SHA-256 is not weak — but a symmetric key must exist everywhere it is verified, so one compromised service can forge tokens for all of them. RS256 hands out only the public half. Note that option 3 is backwards: measured above, the RS256 token was the larger one, 474 bytes against 175.

The checklist

Before a JWT verifier goes near production:

  1. algorithms is an explicit allowlist you wrote, never read from the token's header.
  2. The key is a typed key object (importSPKI, createRemoteJWKSet, createSecretKey), not a raw string or buffer.
  3. HMAC secrets are at least 32 random bytes, per RFC 7518 §3.2 — not a passphrase.
  4. exp is required, and a token missing it is rejected rather than accepted.
  5. iss and aud are both checked against expected values.
  6. Signature comparison is constant-time, and happens before anything in the token is parsed.
  7. Access token lifetime is short enough that you can live with the revocation gap.
  8. kid is treated as untrusted input when it is used to look up a key.

If you want the more paranoid version of this format, PASETO is the usual counter-proposal. Its spec puts the design difference in one line — JWT "gives you 'algorithm agility', Paseto gives you 'versioned protocols'". That trade is real, and so is the ecosystem gap — the spec repo sits at 285 stars against JWT's presence in every identity provider on the planet. Knowing why JWT is shaped the way it is stays the more useful skill, and the eyJ... in your logs is now something you can take apart by hand.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Do [1,4] and [4,5] Overlap? Answer That First
LeetCode 56 merges [1,4] and [4,5]; LeetCode 435 says they do not overlap at all. Closed versus half-open ends is the one real decision in interval problems, and most interval bugs come from never making it.
AdminAugust 11, 20268 min read
Content Security Policy, Broken Four Times
The British Airways skimmer sat in a first-party file, so no host allowlist, no 'self' and no nonce would have stopped it — here is one CSP header tightened four times, broken after each round against the CSP Level 3 matching algorithms, until only the directive nobody writes first is left holding.
AdminAugust 11, 202612 min read
The Garbage Collector Bills You for Survivors, Not Garbage
Five runs of the same one-million-allocation loop on Node 22, changing only how many objects stay reachable, move total GC time from 13 ms to 334 ms — and that single fact explains most of what people get wrong about V8's heap, Go's missing generations, and why Twitch's 10 GiB of useless memory made their API faster.
AdminAugust 10, 202612 min read