Lesson 05 / 23
JSON Web Tokens
The signed token's standard format: the three-part compact serialization, what the registered claims mean, the steps a verifier must not skip, asking the configuration rather than the token for the algorithm, the clock skew allowance, and the signing key's length.
Contents
The previous lesson’s token format was specific to that lesson: body, dot, signature. A widespread standard that does the same job exists — the JSON Web Token (JWT) — and it fixes the fields’ names, how the signature algorithm is stated, and which unit the duration is written in. The real consequence of the format being standardized is that verification is standardized too: which questions must be asked before accepting a token is also defined.
This lesson issues the token by hand and verifies it by hand. Two verifiers are written: one takes the algorithm it expects from its own configuration, the other looks at what the token’s header states. When the decisions the two give for the same tokens are placed side by side, which verification step is indispensable becomes measurable.
Three Parts
A token consists of three base64url parts separated by dots: header, body, and signature. The signature is computed over the encoded forms of the first two parts, not over the decoded JSON. This distinction matters, because the same JSON object has different encodings with different whitespace layouts, and the signature binds the exact bytes the token is carried in.
This three-part representation is called compact serialization; it is the form defined by the JWS standard for signed data, one that can be carried in a header, a query string, or a body.
// jwt.mjs — issuing the JWS compact serialization and two separate verifiers import { createHmac, timingSafeEqual } from "node:crypto"; export const b64 = (v) => Buffer.from(v).toString("base64url"); export const b64Decode = (v) => Buffer.from(v, "base64url").toString("utf8"); const HMAC = { HS256: "sha256", HS384: "sha384", HS512: "sha512" }; const sign = (alg, data, key) => alg === "none" ? "" : createHmac(HMAC[alg], key).update(data).digest("base64url"); // Token issuance: header and body are encoded with base64url, the signature is computed over both. export const issue = ({ alg = "HS256", kid, claims, key }) => { const header = b64(JSON.stringify(kid ? { alg, typ: "JWT", kid } : { alg, typ: "JWT" })); const body = b64(JSON.stringify(claims)); return `${header}.${body}.${sign(alg, `${header}.${body}`, key)}`; }; const isEqual = (a, b) => { const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8"); return x.length === y.length && timingSafeEqual(x, y); }; // CORRECT VERIFIER: the caller fixes the expected algorithm; the header is read only for kid. export const verify = (token, { key, expectedAlg, issuer, audience, clockSkewSec = 0 }) => { const parts = (token ?? "").split("."); if (parts.length !== 3) return { error: "format" }; const [headerB64, bodyB64, signature] = parts; let header, claims; try { header = JSON.parse(b64Decode(headerB64)); claims = JSON.parse(b64Decode(bodyB64)); } catch { return { error: "could not be decoded" }; } // 1. The algorithm comes from the verifier's configuration, not from what the token states. if (header.alg !== expectedAlg) return { error: "alg" }; // 2. The signature is recomputed with the expected algorithm. if (!isEqual(signature, sign(expectedAlg, `${headerB64}.${bodyB64}`, key))) return { error: "signature" }; // 3. Time claims (in seconds), with the clock skew allowance. const now = Math.floor(Date.now() / 1000); if (typeof claims.exp !== "number") return { error: "missing exp" }; if (now >= claims.exp + clockSkewSec) return { error: "expired" }; if (typeof claims.nbf === "number" && now < claims.nbf - clockSkewSec) return { error: "not yet valid" }; // 4. Issuer and audience. if (claims.iss !== issuer) return { error: "issuer" }; const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; if (!aud.includes(audience)) return { error: "audience" }; return { claims }; }; // WRONG VERIFIER: reads the algorithm from the token's header. Stands as the counter-example. export const verifyTrustingHeader = (token, { key }) => { const [headerB64, bodyB64, signature] = (token ?? "").split("."); let header, claims; try { header = JSON.parse(b64Decode(headerB64)); claims = JSON.parse(b64Decode(bodyB64)); } catch { return { error: "could not be decoded" }; } if (!isEqual(signature ?? "", sign(header.alg, `${headerB64}.${bodyB64}`, key))) return { error: "signature" }; return { claims }; };
The issued token’s parts can be read directly.
// structure.mjs — the token's three parts and reading the claims // Usage: node structure.mjs (in the same directory as jwt.mjs) import { issue, b64Decode } from "./jwt.mjs"; const KEY = Buffer.from("this-key-is-used-only-in-this-lesson-32b", "utf8"); const now = 1785000000; // example fixed timestamp (seconds) const token = issue({ alg: "HS256", kid: "2026-01", key: KEY, claims: { iss: "https://auth.library.example", // issuer sub: "U-1001", // subject: the principal the token describes aud: "loan-service", // audience: the service expected to accept the token iat: now, // issued-at moment nbf: now, // not valid before this moment exp: now + 900, // 15 minutes jti: "b-000001", // token identifier role: "member", // service-specific claim }, }); const [header, body, signature] = token.split("."); console.log("token length :", token.length, "characters"); console.log("header :", b64Decode(header)); console.log("body :", b64Decode(body)); console.log("signature:", signature, `(${Buffer.from(signature, "base64url").length} bytes)`); console.log("data the signature is computed over:", `${header.slice(0, 12)}….${body.slice(0, 12)}…`);
token length : 314 characters
header : {"alg":"HS256","typ":"JWT","kid":"2026-01"}
body : {"iss":"https://auth.library.example","sub":"U-1001","aud":"loan-service","iat":1785000000,"nbf":1785000000,"exp":1785000900,"jti":"b-000001","role":"member"}
signature: DHHZh78Z1uVy_bifPm7cVUSd51SucLjXZ2eUbAxwaoo (32 bytes)
data the signature is computed over: eyJhbGciOiJI….eyJpc3MiOiJo…
The token is 314 characters, and a header of this length is carried on every request. Every claim added to the body grows this number; a token is not a data carrier, it is a proof of identity.
Registered Claims
The fields in the body are called claims. Some of them are named in the standard, and their meaning is fixed.
iss— the issuer that produced the token. The verifier keeps the issuer it expects in its own configuration and rejects a mismatch.sub— the principal the token describes. In the loan service, this is a member or staff code.aud— the audience: the recipient the token is expected to be accepted by. It can be a single string or an array of strings.exp— the moment validity ends;nbf— the moment validity begins;iat— the moment of issuance. All three are in seconds since 1970. Confusing this with JavaScript’s millisecond-based timestamp produces a token a thousand times longer- or shorter-lived than intended.jti— the token identifier. The revocation list and reuse detection rest on this field.
The aud claim is the field most often skipped in verification, and skipping it
produces the most concrete consequence. A token issued for the loan service also
passes at the fine service when aud is not checked. If two services trust the same
issuer, this field is the only boundary between them.
Claims that have no place in the standard can also be written — like role above. To
keep these from colliding, a namespace must be reserved for them; and the rule
established in the previous lesson applies here too: because the body is readable, no
secret is written into it.
The Decision on Eight Trials
The script below asks two verifiers about eight different tokens. All of the tokens are trial values this file produces itself.
// verification-matrix.mjs — the decision eight trial tokens get from two verifiers // Usage: node verification-matrix.mjs (in the same directory as jwt.mjs) import { issue, verify, verifyTrustingHeader, b64 } from "./jwt.mjs"; const KEY = Buffer.from("this-key-is-used-only-in-this-lesson-32b", "utf8"); const OTHER_KEY = Buffer.from("this-key-belongs-to-another-service-32byt", "utf8"); const now = Math.floor(Date.now() / 1000); const base = { iss: "https://auth.library.example", sub: "U-1001", aud: "loan-service", iat: now, nbf: now, exp: now + 900, jti: "b-000001", role: "member", }; const make = (change, alg = "HS256", key = KEY) => issue({ alg, key, claims: { ...base, ...change } }); // Token whose body is altered: the signature is left as is. const sound = make({}); const [h, , s] = sound.split("."); const bodyAltered = `${h}.${b64(JSON.stringify({ ...base, role: "staff" }))}.${s}`; const TRIALS = [ ["valid", sound], ["expired", make({ exp: now - 30 })], ["not yet valid", make({ nbf: now + 60 })], ["audience is another service", make({ aud: "fine-service" })], ["issuer is different", make({ iss: "https://other.example" })], ["body altered", bodyAltered], ["header alg: none", make({}, "none", KEY)], ["signed with another key", make({}, "HS256", OTHER_KEY)], ]; const SETTINGS = { key: KEY, expectedAlg: "HS256", issuer: "https://auth.library.example", audience: "loan-service", }; const label = (result) => (result.error ? `reject (${result.error})` : `accept (role=${result.claims.role})`); console.log("trial".padEnd(30) + "fixes the algorithm".padEnd(26) + "trusts the header"); for (const [name, token] of TRIALS) { console.log(name.padEnd(30) + label(verify(token, SETTINGS)).padEnd(26) + label(verifyTrustingHeader(token, SETTINGS))); }
trial fixes the algorithm trusts the header valid accept (role=member) accept (role=member) expired reject (expired) accept (role=member) not yet valid reject (not yet valid) accept (role=member) audience is another service reject (audience) accept (role=member) issuer is different reject (issuer) accept (role=member) body altered reject (signature) reject (signature) header alg: none reject (alg) accept (role=member) signed with another key reject (signature) reject (signature)
The right-hand column shows a verifier that checks only the signature, and it accepts six of the eight trials. Expired tokens, not-yet-valid tokens, tokens issued for another service, and tokens from another issuer all pass, because signature verification asks none of these questions. The signature says “this token was not altered”; it does not say “this token came to you, now, from this issuer.” Verification starts with the signature but does not end with it.
The seventh row produces a separate rule. The alg field sits in the token’s header,
meaning it is a value the token’s issuer wrote. If the verifier reads this value and
acts on it, the token itself has decided which check gets applied. The right-hand
verifier accepts a token that states none: the signature is empty, the computed
signature is also empty, and the comparison passes. The left-hand verifier rejects the
same token at the very first step, because the algorithm it expects is written in its
own configuration.
The rule is this: the verifier takes the algorithm it expects from its own
configuration and reads the header’s alg field only to detect a mismatch. The
header’s kid field is separate from this: kid states which key to use and selects
among the keys the verifier recognizes; it cannot point to a key the verifier does
not recognize.
The Clock Skew Allowance
Time claims depend on two separate machines’ clocks, and clocks are never perfectly synchronized. A strict verifier can reject a token freshly issued by an issuer whose clock runs a few seconds ahead as “not yet valid.” This is why verifiers grant a small clock skew allowance.
// clock-skew.mjs — the effect of the clock skew allowance on the verification decision // Usage: node clock-skew.mjs (in the same directory as jwt.mjs) import { issue, verify } from "./jwt.mjs"; const KEY = Buffer.from("this-key-is-used-only-in-this-lesson-32b", "utf8"); const now = Math.floor(Date.now() / 1000); const SETTINGS = { key: KEY, expectedAlg: "HS256", issuer: "https://auth.library.example", audience: "loan-service", }; const claims = (change) => ({ iss: "https://auth.library.example", sub: "U-1001", aud: "loan-service", iat: now, exp: now + 900, ...change, }); const ask = (token, clockSkewSec) => { const s = verify(token, { ...SETTINGS, clockSkewSec }); return s.error ? `reject (${s.error})` : "accept"; }; const expired = issue({ key: KEY, claims: claims({ exp: now - 2 }) }); const notYetValid = issue({ key: KEY, claims: claims({ nbf: now + 2 }) }); console.log("skew".padEnd(8) + "expired 2s ago".padEnd(28) + "valid in 2s"); for (const skew of [0, 5, 60]) { console.log(`${skew}s`.padEnd(8) + ask(expired, skew).padEnd(28) + ask(notYetValid, skew)); }
skew expired 2s ago valid in 2s 0s reject (expired) reject (not yet valid) 5s accept accept 60s accept accept
The allowance works in both directions and extends the token’s effective lifetime. A five-second allowance makes a fifteen-minute token valid for fifteen minutes and five seconds; a sixty-second allowance starts to erode the point of setting up a short-lived token. The right measure is the magnitude of the clock synchronization error: a few seconds is enough, and minutes mean the allowance is being used to paper over a configuration fault.
The Key’s Length and Ownership
In HMAC signing, the key is a secret, and anyone who knows it can issue a valid token. This is why the signer and the verifier share the same secret. The key’s length does not affect whether verification works; it only determines resilience.
// key.mjs — the HMAC key's length does not block verification, but it does determine resilience import { createHmac } from "node:crypto"; for (const key of ["1234", "library", "this-key-is-used-only-in-this-lesson-32b"]) { const bytes = Buffer.from(key, "utf8").length; const signature = createHmac("sha256", key).update("header.body").digest("base64url"); console.log(`${bytes.toString().padStart(2)} bytes key → signature ${signature.slice(0, 16)}… (length ${Buffer.from(signature, "base64url").length} bytes)`); }
4 bytes key → signature 4n5MWf_MAO6KGupa… (length 32 bytes) 7 bytes key → signature 61aip2hP4tdMtQZM… (length 32 bytes) 40 bytes key → signature Z5HjxrF9MFTs3M4h… (length 32 bytes)
All three signatures are 32 bytes, and none of them reveal that the key is weak. A token issued with a four-byte key passes verification without trouble; the problem is not in verification, it is that the key can be found by trial. This is why key length is not a runtime check but a configuration rule: an HMAC key is generated from random data at least as long as the digest — 32 bytes for SHA-256. A password found in a dictionary does not satisfy this condition, even if its length matches.
The shared secret’s second consequence is a scaling problem. If the loan, catalog, and
fine services all verify the same token, all three know the same secret; a leak from
any one of the three means a leak of the power to issue tokens. Asymmetric signing
separates this: the issuer signs with a private key, verifiers know only the public
key, and a token cannot be issued with the public key. The header’s kid field gains
its meaning here too — the issuer can use more than one key, and the verifier learns
which key the token was signed with from this field and selects among the keys it
recognizes.
Summary
- A JSON web token is three base64url parts separated by dots; the signature is computed over the encoded forms of the header and the body.
- Registered claims have fixed meanings:
isscarries the issuer,subthe principal,audthe audience,exp/nbf/iattimes in seconds,jtithe token identifier. - Signature verification only says the token was not altered; the duration, issuer, and audience checks are separate steps, and skipping them causes tokens issued for another service or already expired to be accepted.
- The verifier takes the algorithm it expects from its own configuration; a verifier
that reads the
algfield and acts on it has left the decision of which check applies to the token itself. - The clock skew allowance extends the token’s effective lifetime; a few seconds accounts for clock synchronization error, while minutes paper over a configuration problem.
- The HMAC key’s length does not affect verification, it determines resilience; a shared secret gives every verifier the power to issue tokens, while asymmetric signing keeps that power with the issuer.
Next Step
The previous lesson’s measurement showed that for a stateless token, the variable that
determines the revocation window is the lifetime. This lesson tied the lifetime to the
exp field, but the dilemma remains: a short lifetime narrows the revocation window,
while forcing the user to log in again frequently. The solution is to use two tokens
instead of one — one short-lived and sent on every request, the other long-lived and
used only for renewal. The next lesson builds this pair, and shows by running it why
the long-lived token must be replaced on every use, and how a second use of the same
token can be detected.
To keep your progress and take notes, Log in
My notes
Log in to take notes.