Skip to main content
Back to all posts

Security

JWT Structure Decoded: Header, Payload, and Signature

A JWT is three Base64URL parts joined by dots — header.payload.signature. The first two are only encoded (anyone can read them); the signature makes it tamper-evident.

MM H Tawfik11 min read

JSON Web Tokens are everywhere — bearer tokens in Authorization headers, session cookies, OAuth access tokens, single-sign-on assertions. And almost everyone who uses them misreads the structure at least once. They think a JWT is encrypted. They put a secret in the payload. They "validate" a token by decoding it and never check the signature. Each of those is a security bug, and each comes from not understanding what the three parts of a JWT actually are.

This guide takes one real token apart, dot by dot, and tells you exactly what each segment does, what it does not do, and where the dangerous gaps are. It's a companion to our hashing vs encryption vs encoding guide — that post draws the conceptual lines; this one shows you the wire format.

TL;DR: what is a JWT made of?

A JWT is three Base64URL-encoded strings joined by dotsheader.payload.signature. The header and payload are only encoded, not encrypted, so anyone holding the token can read them in plaintext. The signature is a keyed hash (or asymmetric signature) computed over the first two parts; it is what makes the token tamper-evident. Critically: decoding a JWT is not verifying it. Reading the claims requires no secret; trusting them requires checking the signature against the issuer's key.

The shape, defined in RFC 7519:

xxxxx.yyyyy.zzzzz
  │      │      │
header payload signature   ← each Base64URL-encoded, joined with "."

That's the entire structure. Everything below is detail.

A real token, taken apart

Here is a complete, well-formed JWT. Three segments, two dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b29say5zaXRlIiwic3ViIjoidV8wMDEyMyIsImF1ZCI6ImFwaSIsImV4cCI6MTc4NzI4MDAwMCwiaWF0IjoxNzg3Mjc2NDAwLCJqdGkiOiJhYmMxMjMifQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Split on the dots and Base64URL-decode each part. The first segment decodes to the header:

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

The second segment decodes to the payload (the claims):

{
  "iss": "toolk.site",
  "sub": "u_00123",
  "aud": "api",
  "exp": 1787280000,
  "iat": 1787276400,
  "jti": "abc123"
}

The third segment, dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk, is the signature — it does not decode to readable JSON, because it's raw bytes (an HMAC digest here) Base64URL-encoded. You can paste the whole token into the JWT decoder and see the header and payload exactly as above, with no key required. That is the whole point of the next section.

Note the encoding is Base64URL, not standard Base64: + becomes -, / becomes _, and trailing = padding is stripped (per RFC 7515 §2). That keeps the token safe to drop into a URL or HTTP header without escaping.

The header: which algorithm signed this?

The header is a JSON object describing how the token is signed and what it is. It is the smallest of the three parts and almost always contains alg and typ.

  • alg — the signing algorithm. This is the single most security-sensitive field in the whole token, because it tells the verifier how to check the signature. Common values:
    • HS256 — HMAC with SHA-256. Symmetric: one shared secret both signs and verifies.
    • RS256 — RSA signature with SHA-256. Asymmetric: a private key signs, a public key verifies.
    • ES256 — ECDSA on the P-256 curve with SHA-256. Asymmetric like RS256 but with much smaller keys and signatures.
  • typ — the media type, almost always "JWT". Some specs use "at+jwt" for OAuth access tokens to disambiguate token types.
  • kid — "key ID". When an issuer rotates or holds multiple signing keys, kid tells the verifier which public key to fetch (typically from a JWKS endpoint). It's how a verifier picks the right key without guessing.

The full registry of header parameters lives in RFC 7515 §4 (JWS) and RFC 7519 §5.

The payload: the claims, readable by anyone

The payload is a JSON object of claims — statements about the subject and the token itself. RFC 7519 §4.1 defines seven registered claims, all optional but all reserved:

| Claim | Name | Meaning | |-------|------|---------| | iss | Issuer | Who minted the token (e.g. auth.example.com) | | sub | Subject | Who the token is about (usually a user ID) | | aud | Audience | Who the token is for — the verifier must be in this list | | exp | Expiration | Unix timestamp after which the token is invalid | | nbf | Not Before | Unix timestamp before which the token is invalid | | iat | Issued At | Unix timestamp when the token was created | | jti | JWT ID | Unique identifier, used to prevent replay / enable revocation |

The time-based claims are NumericDate values — seconds since the Unix epoch (1970-01-01 UTC), not milliseconds. A correct verifier enforces two windows: reject the token if now >= exp, and reject it if now < nbf. Both checks should allow a small clock-skew tolerance (typically 30–60 seconds) because issuer and verifier clocks are never perfectly aligned.

const now = Math.floor(Date.now() / 1000);   // seconds, to match NumericDate
const skew = 60;                              // 60s tolerance
if (claims.exp && now >= claims.exp + skew) reject("expired");
if (claims.nbf && now <  claims.nbf - skew) reject("not yet valid");

Beyond the registered claims you can add any custom (private) claims — role, email, tenant_id, whatever your app needs. Which brings us to the most important rule about the payload:

The payload is Base64URL-encoded, not encrypted. It is plaintext. Anyone who can read the token can read every claim.

Base64URL is reversible with no key — it's encoding, not encryption (see the hashing vs encryption vs encoding breakdown). To prove it to yourself, take the middle segment of any token and run it through the Base64 decoder: the JSON falls right out. So never put a secret in a JWT payload — no passwords, no API keys, no full credit-card numbers, and ideally no sensitive PII. The signature protects against tampering, not against reading. If you genuinely need confidential claims, you want JWE (encrypted tokens, RFC 7516), which is a different construction.

The signature: what makes it tamper-evident

The signature is computed over base64url(header) + "." + base64url(payload) — the exact ASCII bytes of the first two segments joined by a dot. This construction is JSON Web Signature, RFC 7515 (JWS). Change a single character in the header or payload and the recomputed signature won't match, so the forgery is detected. That is the whole security model: the claims travel in the clear, but they're sealed.

There are two ways to produce that seal, mapping directly to the symmetric/asymmetric split:

Symmetric — HS256 (HMAC-SHA256). The signature is HMAC-SHA256(secret, header + "." + payload). One shared secret both creates and verifies the signature. It's fast and simple, but anyone who can verify a token can also forge one — so the secret can only live on trusted servers, never in a browser or mobile app. HMAC is itself a keyed hash defined in RFC 2104; you can compute one by hand with the HMAC generator to watch how the digest changes when the input does. (For the underlying plain hash functions, the hash generator shows SHA-256 directly.)

Asymmetric — RS256 / ES256. The issuer signs with a private key; verifiers check with the matching public key. The private key never leaves the issuer, so you can hand the public key to a hundred microservices and any of them can verify a token while none of them can mint one. This is what you want when an identity provider issues tokens that many independent services consume.

| | HS256 | RS256 | |---|-------|-------| | Key type | One shared symmetric secret | Asymmetric key pair (private + public) | | Who can sign | Anyone with the secret | Only the holder of the private key | | Who can verify | Only holders of the same secret | Anyone with the public key | | Best for | Single trusted backend that both issues and checks | One issuer, many independent verifiers (microservices, SSO) | | Key distribution | Hard — the secret must stay secret on every party | Easy — publish the public key (e.g. via JWKS) | | Forgery risk | Verifier can also forge | Verifiers cannot forge |

Decode vs verify: the distinction that bites people

This is the single most common JWT mistake, so it gets its own section. Decoding and verifying are two completely different operations.

  • Decoding = Base64URL-decode the header and payload to read the JSON. It needs no key, proves nothing, and can be done by anyone holding the token — including an attacker who modified it.
  • Verifying = recompute the signature over the header and payload using the issuer's key, confirm it matches the token's third segment, and then check exp, nbf, aud, and iss. Only a token that passes all of this is trustworthy.

A worked example of the trap: an attacker grabs a valid token, edits the payload from "role":"user" to "role":"admin", and re-Base64URL-encodes it. The decoded payload now says admin. If your server merely decodes and reads role, you just granted admin. But the signature no longer matches the tampered payload, so a server that verifies rejects it outright. Decoding believes the attacker; verifying catches them.

The JWT decoder is deliberately a decoder, not a verifier — it inspects and pretty-prints the header and payload so you can debug claims, but it does not check the signature against a secret or key for you (it runs 100% in your browser and never sees your signing keys). Use it to read a token; use your auth library (with the correct key and a pinned alg) to trust one.

Security gotchas you must guard against

Most JWT vulnerabilities are not breaks in the crypto — they're verifiers being too trusting. The classics, drawn from the OWASP JWT Security Cheat Sheet and Auth0's JWT best practices (the IETF BCP, RFC 8725):

  1. The alg: none attack. Early JWT libraries honored a header of {"alg":"none"}, which declares no signature — so an attacker strips the signature, sets alg to none, and the token is "valid". Never accept unsigned tokens. Pin the expected algorithm on the verifier and reject none outright.
  2. Algorithm confusion (RS256 → HS256). If a verifier picks the algorithm from the token's header, an attacker can take a public RS256 key, switch alg to HS256, and sign a forged token using that public key as the HMAC secret. The server, configured for RSA, uses the same public key to verify — and it matches. Fix: the verifier, not the token, decides the algorithm. Hardcode the expected alg.
  3. Not validating exp. A token with no expiry check is a permanent credential. Always enforce exp (and nbf if present), with bounded clock-skew tolerance.
  4. Not checking aud / iss. A token minted for service A should not be accepted by service B. Verify the audience and issuer match what you expect, not just that the signature is valid.
  5. Leaking PII in the payload. Because the payload is readable by anyone (it's just Base64), every claim is effectively public to whoever holds the token. Keep it to identifiers and authorization data; don't ship email addresses, phone numbers, or anything regulated.
  6. Long-lived tokens with no revocation. Stateless JWTs can't be "logged out" server-side without extra machinery. Use short exp windows plus refresh tokens, and track jti if you need a revocation list.

The throughline: trust comes from verifying the signature with a key you control and a pinned algorithm — never from what the token says about itself.

TL;DR

A JWT is three Base64URL-encoded parts joined by dots — header.payload.signature (RFC 7519). The header names the signing algorithm (alg: HS256 symmetric, RS256/ES256 asymmetric), typ, and optionally kid. The payload carries claims — registered ones like iss, sub, aud, exp, nbf, iat, jti — and is only encoded, not encrypted, so anyone can read it; never store secrets or sensitive PII there. The signature (RFC 7515 / JWS) is computed over header.payload with HMAC (shared secret) or RSA/ECDSA (private key signs, public key verifies), and it's what makes the token tamper-evident. The cardinal rule: decoding is not verifying — reading the claims needs no key, but trusting them means recomputing the signature with the issuer's key, pinning the algorithm (to stop alg:none and RS256→HS256 confusion), and checking exp/nbf/aud/iss.

Inspect a real token in the JWT decoder, prove the payload is plaintext with the Base64 decoder, build a signature with the HMAC generator or hash generator, and browse every developer utility in the tools directory.