---
title: 'JSON Web Tokens'
source: 'https://academia.sh/en/courses/authentication-and-authorization/json-web-tokens'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:31+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```js
// 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**.

```js
// 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.

```js
// 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: `iss` carries the issuer, `sub` the
  principal, `aud` the audience, `exp`/`nbf`/`iat` times in seconds, `jti` the 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 `alg` field 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.
