Course outline · 0% complete

0/29 lessons0%

Course overview →

JWTs, Step by Step

lesson 7-3 · ~13 min · 22/29

Three parts joined by dots

The standard token format is the JWT (JSON Web Token). It is three chunks of text joined by dots:

header.payload.signature
  • header: JSON naming the signing algorithm, like {"alg":"HS256","typ":"JWT"}.
  • payload: JSON with the claims, the facts the token asserts: sub (subject, the user id), name, exp (expiry timestamp).
  • signature: proof the server issued it (next section).

Header and payload are encoded with base64url: a way to write any bytes using only URL-safe characters. Encoding is NOT encryption. It is a reversible re-spelling, and anyone can decode it. Run the example and decode a payload yourself.

The token header base64url JSON . payload base64url JSON . signature HMAC, needs the secret readable by anyone who holds the token forgeable by nobody HMAC_SHA256(head + "." + body, server secret) recomputed value matches the third part then the claims are trusted, with no database lookup
A JWT's three dot-separated parts, and the verify step that recomputes the signature from the first two.

Encoding is not encryption

Buffer.from(...).toString("base64url") encodes, and decoding needs no secret at all, which proves the payload is readable by anyone holding the token.

function base64url(obj) {
  return Buffer.from(JSON.stringify(obj)).toString("base64url");
}

const header = { alg: "HS256", typ: "JWT" };
const payload = { sub: "42", name: "Ada" };

console.log(base64url(header) + "." + base64url(payload) + ".SIGNATURE");

// decoding is trivial, no secret required:
const decoded = Buffer.from(base64url(payload), "base64url").toString();
console.log(decoded);

Output

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBZGEifQ.SIGNATURE
{"sub":"42","name":"Ada"}

The second line is the whole point of the example. The original JSON comes back with no key, no password, and no server involvement, which is what "encoding" means as opposed to "encryption".

Pasting any real JWT into a base64url decoder shows its claims, and browser dev tools will do it for you. Treat a token's payload as public information, so never put a password, an internal note, or anything private in it.

The eyJ prefix on both encoded chunks is a small piece of trivia worth recognizing. It is what {" encodes to, so a string starting with eyJ is almost always base64-encoded JSON, and spotting it in a log tells you what you are looking at.

base64url rather than plain base64 matters because tokens travel in URLs and headers. It replaces the + and / characters and drops the trailing = padding, so nothing in a token needs escaping.

Note that the third part here is the literal text SIGNATURE, which no server would accept. Producing the real one requires the secret, which is the next block.

The signature is the whole point

If anyone can decode the payload, anyone can also build a payload claiming to be user 1. What stops them is the third part.

The signature is an HMAC: a hash of header.payload mixed with a secret key only the server knows.

signature = HMAC_SHA256(header + "." + payload, secret)

When a token comes back with a request, the server recomputes the HMAC over the first two parts and compares it to the third:

  • Payload tampered with? The recomputed value no longer matches. Rejected.
  • Signature forged without the secret? Cannot produce a matching value. Rejected.

So the server trusts the claims without any database lookup, which is exactly the token lane from lesson 7-2. Now implement verify, it is the same HMAC call that sign uses.

Implementing verify

verify(token, secret) splits the token on dots, recomputes the HMAC over the first two parts, and compares it to the third.

const crypto = require("node:crypto");

function base64url(obj) {
  return Buffer.from(JSON.stringify(obj)).toString("base64url");
}

function sign(payload, secret) {
  const head = base64url({ alg: "HS256", typ: "JWT" });
  const body = base64url(payload);
  const signature = crypto
    .createHmac("sha256", secret)
    .update(head + "." + body)
    .digest("base64url");
  return head + "." + body + "." + signature;
}

function verify(token, secret) {
  const [head, body, signature] = token.split(".");
  const expected = crypto
    .createHmac("sha256", secret)
    .update(head + "." + body)
    .digest("base64url");
  return signature === expected;
}

const token = sign({ sub: "42", name: "Ada" }, "my-secret");
console.log(verify(token, "my-secret"));
console.log(verify(token, "wrong-secret"));

const tampered = token.slice(0, -2) + "xx";
console.log(verify(tampered, "my-secret"));

Output

true
false
false

verify recomputes exactly what sign computed, over the same head + "." + body string with the same algorithm. Any difference in that input, including a single character, produces a completely different HMAC.

The three results cover the three cases that matter. The right secret over an untouched token passes, the wrong secret fails, and a modified token fails even with the right secret.

Note what verify does not do, which is look anything up. There is no database, no session store, and no network call, so the check costs one hash and works on any server holding the secret.

Note also what it does not check. A valid signature says the server issued this token and says nothing about expiry, so a real implementation must also compare the payload's exp claim against the current time.

Production code uses a timing-safe compare rather than ===, through crypto.timingSafeEqual. A plain comparison can leak how many leading characters matched via how long it took, which is a narrow attack and a real one.

The other reason to use a library such as jsonwebtoken is the alg header. A naive verifier that trusts the header's stated algorithm can be tricked into accepting alg: "none", and that flaw shipped in several real implementations, which is the topic the next lesson opens with.

A user who edits the payload

If someone decodes { sub: "42", role: "user" }, changes the role to "admin", re-encodes it, and sends it back, verify fails, because the signature was computed over the original payload and the recomputed HMAC no longer matches.

Editing the payload changes the input to the HMAC, so the old signature cannot match. Producing a new valid signature requires the server's secret, which the user does not have.

The attack costs nothing to attempt and is worth understanding for that reason. Decoding and re-encoding a JWT takes seconds with a browser console, so this is not a theoretical adversary, and every public API sees it tried.

That is why the payload may be readable but not forgeable, and why the secret must never leak or be guessable. A secret in a Git repository, a secret like "secret", and a secret shared with a client-side app are all equivalent to having no signature at all.

The failed verification should also produce a 401 and a log line, not a crash. A tampered token is an ordinary hostile input, and handling it is the same shape as any other validation failure.

The part that makes claims trustworthy

It is the signature.

It is an HMAC of header.payload computed with the server's secret key, so any change to the claims makes the recomputed HMAC disagree with the attached signature, and nobody without the secret can produce a matching one.

The header and payload contribute nothing to trust, which is worth stating plainly. They are readable, editable, and re-encodable by anyone, and their only protection is that the signature covers them.

Readable but unforgeable is the entire JWT trade. Never put private data in a payload, and guard the signing secret with your life, since the secret is the single thing standing between a stranger and an admin claim.

The practical checklist that follows from this is short. Keep the secret in an environment variable rather than the code, rotate it if it might have leaked, use a long random value, and never ship it to a browser or mobile app.