The shape of a JWT

A JWT (JSON Web Token, RFC 7519, pronounced "jot" by people who care about that kind of thing) is three pieces of Base64URL-encoded text joined with dots. It looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzQ1MTk1MjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The three pieces are header, payload, and signature, in that order. Decode the first two with any Base64 tool and you get JSON. Decode the third and you get binary — that one isn't meant to be human-readable. The dots aren't separators in any clever sense; they're just dots. JWTs were designed to fit in URL parameters and cookie values without further escaping, which is why the encoding is Base64URL (no +, no /, no padding) and why the boundaries are visible characters instead of length prefixes.

Decoding all three pieces of the example above gives:

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

// Payload
{ "sub": "1234567890", "name": "Alice", "iat": 1745195200 }

// Signature
[40 bytes of opaque binary]

That's the entire structure. There is nothing else hidden in there. The payload is plain text — anyone holding the token can read every claim inside it. JWTs are signed, not encrypted. The signature stops anyone from changing the contents without invalidating it; the signature does nothing to stop them from reading the contents.

What's in the header

The header is short and almost always identical across the JWTs you'll see. Two fields matter:

alg — the signing algorithm. Most JWTs in the wild use HS256 (HMAC-SHA-256, symmetric — the issuer and verifier share a secret) or RS256 (RSA-SHA-256, asymmetric — the issuer signs with a private key, anyone can verify with the public key). You'll occasionally see ES256 (ECDSA), EdDSA, or one of the variants — they're cryptographically newer and equivalent for most purposes. The string none is also valid in the spec; if you ever see "alg": "none" on a real token, treat it as a security incident, because it means the signature is empty and anyone can forge tokens.

typ — almost always JWT. This field exists to distinguish JWTs from other JOSE objects (JWS, JWE) when they share the same transport. In practice you can ignore it.

Sometimes you'll see kid (key ID), used when the issuer rotates between multiple signing keys and needs to tell the verifier which one signed this particular token. If you see it, check what key your verifier looked up.

What's in the payload

The payload is where the actual information lives. RFC 7519 defines seven "registered" claim names — these are the well-known ones every library understands. Anyone can also add custom claims; the registered names are just naming conventions to avoid collisions across different systems.

The seven registered claims:

  • iss (issuer) — who minted this token. A URL or short identifier of your auth server. "iss": "https://auth.acme.com".
  • sub (subject) — who the token is about. Usually a user ID. The verifier should treat iss + sub as the unique identifier, never sub alone, because two issuers could pick the same subject ID.
  • aud (audience) — who the token is for. A string or array of strings. If your service is the audience, you should be in this list; if you aren't, the token wasn't issued for you and you should reject it even if the signature is valid.
  • exp (expiration) — Unix epoch seconds at which the token expires. Verifiers MUST reject tokens past their exp. The clock you compare against is your local clock, which means clock drift between issuer and verifier matters; most libraries allow a 30-second leeway.
  • nbf (not before) — Unix epoch seconds. Tokens are invalid before this time. Used for tokens that should activate later (rare in practice, but useful for delayed-start access grants).
  • iat (issued at) — Unix epoch seconds when the token was minted. Useful for invalidating all tokens older than some threshold (e.g., "log everyone out who logged in before our security incident").
  • jti (JWT ID) — a unique identifier for this specific token. Used for revocation lists ("this specific JWT can no longer be used") and to prevent replay attacks.

Note: all the time fields are Unix epoch seconds, not milliseconds. JavaScript's Date.now() returns milliseconds, so you have to divide by 1000. Forgetting this is the second-most-common JWT bug; most tokens land in 1970 or somewhere in the year 53,000.

Beyond the registered claims, anything else in the payload is custom. Common patterns: email, name, roles, permissions, org_id. Some auth providers prefix custom claims with a URL to namespace them ("https://acme.com/roles": ["admin"]) — that's also fine and recommended for tokens that travel across systems.

What the signature actually proves

The signature is the entire reason JWTs are useful. Without it you'd just have a JSON blob the user could rewrite to escalate themselves to admin. With it, the verifier can check whether the contents have been tampered with — but only if the verifier actually checks.

The signature is computed over the literal Base64URL-encoded header and payload (with the dot between them), not over the decoded JSON. That means even reformatting the JSON — adding whitespace, reordering keys, changing escape sequences — invalidates the signature. The signing function is roughly:

signature = HMAC_SHA256(secret, base64url(header) + "." + base64url(payload))
// or, for asymmetric:
signature = RSA_SIGN(privateKey, base64url(header) + "." + base64url(payload))

The verifier reverses this: it takes the header and payload as received (still Base64URL-encoded), recomputes the signature with the shared secret or public key, and compares byte-for-byte against the third part of the token. Match means the token is authentic and unmodified. Mismatch means somebody changed it, somebody used the wrong key, or you're looking at a token from a different issuer.

Critically: decoding a JWT and verifying a JWT are different operations. Decoding means Base64URL-decoding the three parts so you can see the contents — anyone can do this, no key required. Verification means cryptographically checking the signature against a known key, which proves the token was issued by someone who holds that key. A decoder that doesn't verify gives you the contents but can't tell you whether to trust them. Most online JWT decoders, including the one on this site, are decoders only — they make no claim about authenticity. To verify, you need the secret (HS256) or public key (RS256), which you should never paste into a website.

Four mistakes worth knowing about

Trusting the contents without verifying the signature. The classic. Someone receives a token, Base64-decodes the payload, reads "role": "user", and routes accordingly. The user changes user to admin, re-encodes, sends the token back. If the server isn't checking the signature, the user is now an admin. Always verify before trusting any claim in the payload.

Accepting alg: none. The 2015 vulnerability that taught the entire industry to be paranoid about JWT libraries. The spec allows "alg": "none" for unsigned tokens; some libraries would happily verify those tokens against any key (i.e., trivially accept them). The fix is to maintain an allowlist of acceptable algorithms in your verifier, not to delegate that choice to the token itself. Most modern libraries reject none by default; check yours.

Putting secrets in the payload. The payload is plain text. Anyone holding the token can read it. Don't put passwords, API keys, full credit card numbers, or anything you'd be embarrassed to see in a debug log. email and name are usually fine; password_hash is not.

Treating exp as immutable. Once a token is issued, you can't shorten its exp. If you need a token to become invalid before its expiration — because the user logged out, or you're responding to a security incident — you need a separate revocation mechanism. The simplest is a server-side blocklist of jti values; the most common is to issue short-lived access tokens (15 minutes) alongside long-lived refresh tokens, so revocation is just a matter of refusing the next refresh. Pure stateless JWT auth without any server-side state means you can't revoke anything until exp elapses naturally.

When NOT to use JWTs

JWTs are great for stateless cross-service authentication, where multiple backends need to verify the same claims without consulting a shared session store. They're also great for short-lived authorization grants (the token in an OAuth callback, a one-time email verification link).

They're a poor fit for traditional user sessions in a single web app. If your frontend talks to one backend and the backend has a database, a regular session cookie with a server-side session record is simpler, smaller (a session ID is 32 bytes; a JWT is 400+), and supports revocation natively. The reason "sessions" lost the language fight to "tokens" in the late 2010s was developer fashion, not engineering merit. If you're not doing distributed verification, sessions are still the right answer.

Companion tool

If you have a JWT in front of you right now and want to see what's inside it: JWT Decoder. Paste the token, it splits and decodes the three parts, identifies the algorithm, flags expired tokens, and counts down to expiration. Everything happens in your browser — the token never leaves your machine. The same decoder also handles Base64URL on its own, in case you've stripped the dots and just have a fragment.

If you want to learn the encoding side: Base64 Everything handles both standard and URL-safe variants, the latter being what JWTs use internally. Decoding the payload of a JWT is exactly Base64URL-decoding the middle segment.