Skip to main content

How a JWT Is Built: Header, Payload, and the Signature That Makes It Trusted

A practical look at building a JSON Web Token by hand: the three base64url parts, registered claims like exp and iat, and why the signature is what your server checks.

Published By Li Lei
#jwt #encoding #authentication #developer-tools

How a JWT Is Built: Header, Payload, and the Signature That Makes It Trusted

A JSON Web Token looks like a single ugly string with two dots in it, and most developers treat it that way: copy it, paste it into a header, hope it works. But the structure is simple once you take it apart, and understanding it saves you from a whole genre of "signature invalid" bugs that eat an afternoon. A JWT is three base64url-encoded parts joined by dots — the header, the payload, and the signature. That is the entire shape. Everything else is detail about what goes inside each part and how the third part gets computed.

I'll walk through each piece, encode a small token by hand so you can see the dots line up, and explain why the signature is the only thing standing between your API and a forged admin token.

The three parts and the two dots

Write out a token and the layout is obvious once you know to look for it:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIn0.<signature>

Split on the dots and you get exactly three segments:

  1. Header — a tiny JSON object that says which algorithm signed the token, e.g. {"alg":"HS256","typ":"JWT"}.
  2. Payload — the claims, your actual data: who the token is for, when it expires, what role they have.
  3. Signature — a cryptographic check computed over the first two parts plus a secret.

Each segment is base64url-encoded, not plain base64. The difference matters: base64url swaps + for - and / for _, and strips the trailing = padding, so the whole token can sit safely inside a URL or an HTTP header without being mangled. The first two parts are reversible — anyone can base64url-decode them and read your claims in cleartext. A JWT is signed, not encrypted. Never put a password or anything secret in the payload, because it is one decode away from being readable.

Encoding a token by hand

Let's build a real one so the abstraction turns concrete. Start with a header and a payload:

Header:  {"alg":"HS256","typ":"JWT"}
Payload: {"sub":"42","role":"user","iat":1718409600,"exp":1718413200}

Step one: minify each object (no extra whitespace — whitespace changes the bytes and therefore the signature) and base64url-encode it.

base64url(header)  = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
base64url(payload) = eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzE4NDA5NjAwLCJleHAiOjE3MTg0MTMyMDB9

Step two: join those two strings with a dot. That joined string — header.payload — is the signing input. Nothing fancy yet; you could produce this much with any base64 routine.

Step three is where the trust comes in. Take an HMAC-SHA256 of the signing input using your secret as the key, then base64url-encode the raw HMAC output. Append it after a third dot:

signature = base64url(HMAC_SHA256("header.payload", secret))
token     = header.payload.signature

The result is the token at the top of the previous section. Change one byte of the header, one byte of the payload, or one character of the secret, and the signature comes out completely different. That sensitivity is the whole point.

Why the signature is what makes it trusted

Here is the part people skip. The header and payload are public and forgeable — anyone can write {"role":"admin"} and base64url-encode it. So what stops an attacker from minting their own admin token?

The signature. For HS256 it is an HMAC over the first two parts; for RS256 or ES256 it is an RSA or elliptic-curve signature over the same input. Either way, producing a valid signature requires the secret (HMAC) or the private key (RSA/EC). When your server receives a token, it recomputes the signature from the header, payload, and its own copy of the secret, then compares. If even one byte of the payload was tampered with after signing, the recomputed signature won't match and the token is rejected.

So the claims are trusted not because they're hidden, but because they're sealed. An attacker can read everything inside the token, but the moment they edit role from user to admin, the signature stops matching and the server returns a 401. That asymmetry — readable but unalterable without the key — is what lets a stateless server believe a string it just received over the wire.

This is also why secret length matters. RFC 7518 asks for a secret at least as long as the hash output: 32 bytes for HS256, 48 for HS384, 64 for HS512. A short or guessable secret means an attacker can brute-force it offline and then forge valid signatures at will, and the whole scheme collapses.

The registered claims worth knowing

The payload can hold anything, but the JWT spec reserves a handful of short claim names with agreed meanings. You'll see these everywhere:

  • sub (subject) — who the token is about, usually a user ID.
  • exp (expiration time) — a Unix timestamp in seconds after which the token is invalid. This is the one people get wrong most: exp is seconds, not milliseconds. Writing 1718413200000 pushes expiry to the year 56992 and your token effectively never dies. Use Math.floor(Date.now()/1000) + 3600 for a one-hour token.
  • iat (issued at) — when the token was created, also Unix seconds.
  • nbf (not before) — a time before which the token must not be accepted.
  • iss (issuer) and aud (audience) — who minted the token and who it's meant for.

You're free to add custom claims like role or tenant alongside these. Keep the payload small — every claim travels on every request — and remember that exp, iat, and nbf are the ones your validation library actually enforces.

Build and inspect one locally

The reason I reach for a local encoder rather than an online "JWT generator" that POSTs to a backend: the secret is the whole game. If you paste a signing secret into a tool that sends it to a server, you've handed over the key that mints every token your service trusts. A token built client-side with the Toolora JWT encoder signs entirely in the browser via the native SubtleCrypto HMAC — the secret never leaves your tab, isn't logged, and isn't synced into the URL.

My own workflow looks like this. When a teammate reports a token their server rejects, I don't argue over Slack. I paste their exact secret and payload into the encoder, sign it, then drop the result into the JWT decoder to confirm the claims read back correctly. Nine times out of ten the bug is a trailing newline in their .env secret or JSON whitespace they forgot to minify — both of which change the signing input and therefore the signature. Two minutes of side-by-side encoding beats an hour of guessing.

If you want to go a layer deeper and watch HMAC behave the way the signature relies on, the hash generator lets you run SHA-256 over arbitrary input and confirm for yourself that the same bytes always produce the same digest, and that a single changed character cascades through the entire output. That determinism is exactly what makes signature comparison a reliable trust check.

Putting it together

A JWT is not magic and it's not encryption. It's three base64url strings joined by dots: a header naming the algorithm, a payload carrying readable claims, and a signature that's an HMAC or RSA computation over the first two. The claims are public; the signature is what makes them trustworthy, because only someone with the secret or private key can produce one that matches. Get the secret length right, keep exp in seconds, mind your whitespace, and sign locally so the key never leaves your machine — and the token will do exactly what it's supposed to.


Made by Toolora · Updated 2026-06-13