JWTs explained: what is inside a token, how to decode one safely, and the mistakes that get apps breached

A JWT is three chunks of Base64url text joined by dots, and roughly half of what people believe about it is wrong. It is not encrypted. It cannot be revoked by deleting it from the browser. Its signature does not prove the user is who they say they are — it proves the token has not been edited since something signed it, which is a narrower claim than it sounds. This guide takes a token apart segment by segment, explains which claims actually matter and why, walks through the attacks that have broken real deployments (all of them variations on “the server trusted the token to describe how to check the token”), and is honest about the one thing every JWT article should say and most do not: pasting a live production token into a website you do not control is handing over a working credential. The JWT decoder here runs entirely in your browser for exactly that reason, and this guide explains how to confirm that rather than take it on trust.

What the three segments are

The everyday JWT is a JWS in compact serialization: header.payload.signature. Take the standard example token, split it on the dots, and Base64url-decode the first two parts:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9        →  {"alg":"HS256","typ":"JWT"}
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6...   →  {"sub":"1234567890","name":"John Doe","iat":1516239022}
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  →  32 raw bytes of HMAC-SHA256

The header says how the token was signed (alg) and what it is (typ), and often carries a kid key identifier so the verifier knows which of several keys to use. The payload is a plain JSON object of claims. The signature is computed over the exact ASCII string header.payload — the encoded text, not the decoded JSON — which is why you must never re-serialise a token before verifying it. Change a single byte of either segment and the signature no longer matches.

Notice what is missing: any secrecy. Base64url is an encoding, not a cipher. Anyone holding the token — a browser extension, a log aggregator, a proxy, whoever finds the URL in a referrer header — can read every claim. Never put anything in a JWT you would not print on a postcard: no internal user notes, no email addresses you would rather not leak, no roles you would rather not advertise, and obviously no secrets.

Base64url, and why an ordinary decoder chokes

JWT segments use the URL-safe Base64 alphabet from RFC 4648 §5: - in place of +, _ in place of /, and the = padding stripped. That is a deliberate choice so a token survives being put in a URL or a header. It also means the standard Base64 decoder will refuse a segment containing - or _ and answer Invalid Base64 input. To decode by hand, swap the two characters back and pad the length up to a multiple of four with =.

The JWT decoder does that translation for you and adds one thing a raw Base64 decode cannot: it converts the numeric time claims into readable dates. Paste a token and the output pane holds the decoded header, the decoded payload, and a readableTimestamps block turning exp, iat and nbf into ISO 8601 strings — because 1516239022 tells you nothing at a glance and 2018-01-18T01:30:22.000Z tells you the token you are debugging expired years ago. A token with fewer than two segments is rejected with //Invalid JWT: expected header.payload.signature segments; one whose segments are not valid Base64url JSON gives //Invalid JWT: could not decode token segments.

What the decoder pointedly does not do is verify. There is no key field, because a page that asks for your signing key is a page you should close. If you want to be sure of that claim rather than trust it: open the page, turn off your network connection, and paste a token — it still decodes, because the work happens in the tab. The site also serves the tool as a static page you can load once and use offline.

The claims worth knowing

Seven claim names are registered in RFC 7519, and the ones people skip are the ones that cause incidents:

ClaimMeaningWhat goes wrong when it is ignored
issIssuerA token minted by a different tenant or a test identity provider is accepted in production
subSubject — who the token is aboutUsually the user ID; treat it as opaque and unique only within an issuer
audAudience — who the token is forA token issued for service A is replayed against service B. This is the most commonly skipped check and the most useful one in a microservice estate
expExpiry (seconds since epoch)Tokens live forever; a leak from 2022 still works today
nbfNot valid beforeRarely used, occasionally the reason a freshly issued token is rejected by a server whose clock runs slow
iatIssued atLets you enforce a maximum age independently of exp, and spot tokens minted before a password change
jtiToken IDWithout one you have nothing to put on a denylist when you need to revoke a single token

All the time claims are seconds since the Unix epoch, not milliseconds. Writing Date.now() into exp is a perennial bug: it produces a timestamp roughly 50,000 years in the future, and the token never expires. If a decoded date looks absurd, drop the last three digits and check again — the timestamp converter reads both units and shows you which one you have.

How the signature is made

The alg header names the algorithm, and there are three families in practical use:

  • HS256 / HS384 / HS512 (HMAC). One shared secret both signs and verifies. Simple, fast, and only appropriate when the same trust boundary owns both ends — anyone who can verify can also mint. The secret must be a long random string; a dictionary word here is a genuinely broken system, because an attacker with any token can brute-force the secret offline and then issue whatever tokens they like.
  • RS256 / PS256 (RSA). A private key signs, a public key verifies. This is what identity providers use — they publish a JWKS endpoint of public keys, and every service verifies without holding anything sensitive. RS256 uses PKCS#1 v1.5 padding; PS256 uses PSS and is the modern preference.
  • ES256 (ECDSA) and EdDSA (Ed25519). Same public/private split, much smaller signatures and keys. EdDSA is the best default for new systems where you control both sides of the library choice.

A common misunderstanding: the HMAC signature is not “SHA-256 of the token”. HMAC mixes a key into the hash in a specific two-pass construction, so a plain digest tool cannot verify a JWT. The hash generator here computes MD5 and the SHA family over text or a file — the right tool for checksums, and the wrong tool for token verification. Use a JWT library on the machine that holds the key.

The attacks, and the one-line fixes

Almost every JWT vulnerability comes from the same root cause: the server let the token dictate how it should be validated.

  • alg: none.The spec defines an “unsecured JWT” with no signature at all. Early libraries honoured it, so an attacker could strip the signature, set "alg":"none", edit the payload to "role":"admin" and be believed. Fix: pin the expected algorithm at the verify call and reject everything else, including none.
  • HS/RS key confusion. The server verifies with the identity provider’s public key, which is public. An attacker changes alg from RS256 to HS256 and signs the token using that public key as the HMAC secret. A library that picks its verification mode from the header accepts it. Fix: same as above — the algorithm is a property of your configuration, never of the token.
  • kid injection. kid is attacker-controlled text that many servers use to look up a key — as a file path, a database row, or a URL. Path traversal (../../dev/null as the key, so the secret is the empty string), SQL injection and SSRF have all been landed through it. Fix: treat kid as an opaque lookup into a fixed allowlist of keys.
  • Weak HMAC secrets. Given one token, an attacker can test billions of candidate secrets per second offline; published wordlists of leaked JWT secrets exist. Fix: 32 bytes of cryptographic randomness — the generator here will produce one — kept in a secret manager, not in the repository.
  • Missing claim checks. A valid signature only means the token is authentic; it does not mean the token is for you, still valid, or from the issuer you expect. Fix: verify iss, aud and exp on every request, with a clock-skew tolerance of a minute or two and no more.

Expiry, skew and the revocation problem

A JWT is a bearer token: whoever holds it is treated as the subject, and there is no round trip to a session store that could say “this one is cancelled”. That statelessness is the whole point — and the reason logout is harder than it looks. The workable patterns are:

  • Short access tokens. 5–15 minutes, paired with a long-lived refresh token that is stored server-side and can be revoked. The blast radius of a leak becomes minutes.
  • A denylist keyed on jti. Entries only need to live until the token would have expired anyway, so it stays small.
  • A token version claim. Store an integer per user, include it in the token, bump it on logout-everywhere or password change — one write invalidates every outstanding token for that account.

On skew: exp and nbfare compared against the verifier’s clock, and servers drift. Allow a small tolerance, and if freshly issued tokens are being rejected intermittently, check NTP on the verifying host before you change any code.

Debugging a token, safely

Most JWT debugging is a five-minute job: decode, read the claims, compare with what the server expected. The order that finds the problem fastest is: check exp in real time first (expired tokens explain most sudden 401s), then aud and iss against what the verifier is configured to accept, then alg and kid against the key you think is in use, and only then the payload claims your application logic reads.

As for where to do that decoding: a token in your clipboard is a live credential until it expires. Pasting one into an online decoder sends it to whoever runs that site if the decoding happens on their server, and plenty of them do. The safe options are a local library, your browser’s devtools console, or a decoder that provably never transmits the token — which is why the one here does everything in the page, asks for no key, and works with the network off. When in doubt, mint a test token with the same shape and debug that instead of the production one.

Do this

  • Treat the payload as public: no secrets, no data you would not put on a postcard.
  • Pin the algorithm in your verify call. Never let the token’s own alg choose the verification mode, and never accept none.
  • Check exp, iss and aud on every request — a valid signature alone does not mean the token is for your service.
  • Use 32 bytes of real randomness for an HMAC secret, or move to RS256/EdDSA with a JWKS endpoint.
  • Write time claims in seconds, not milliseconds, and sanity-check them with a timestamp converter.
  • Keep access tokens short and pair them with a revocable refresh token; add jti so a single token can be denied.
  • Never paste a live token into a decoder whose page you have not confirmed is client-side only.

Frequently asked questions

Is a JWT encrypted?

Almost never. The common kind (a JWS) is signed, not encrypted: the header and payload are Base64url-encoded, which anyone can reverse in a second. The signature stops the token being modified, not read. There is an encrypted variant, JWE, which has five dot-separated segments instead of three — if your token has three segments, everyone who holds it can read every claim in it.

Can I verify a JWT signature in a browser tool?

Only if you give the tool the signing key, which is exactly what you should not do with a shared website. The decoder here deliberately does not ask for a key and does not verify — it shows you the header and payload and nothing more. Verify on the server that owns the key, or locally with a library.

How do I revoke a JWT?

You cannot, in the general case — that is the trade-off you accepted for stateless verification. The practical answers are short expiry (5–15 minutes) plus refresh tokens, a server-side denylist of jti values until they expire, or a per-user token version claim you bump on logout or password change, which invalidates every outstanding token for that user.

Why does the Base64 decoder reject a JWT segment?

JWT segments use the Base64url alphabet: - instead of +, _ instead of /, and no = padding. A plain Base64 decoder rejects those characters. Replace - with + and _ with /, pad the length to a multiple of four with =, and it will decode — or just use the JWT decoder, which does that translation for you.

Should I store the token in localStorage or a cookie?

A cookie with HttpOnly, Secure and SameSite is the safer default because JavaScript cannot read it, so an XSS bug cannot exfiltrate the token — at the cost of needing CSRF protection. localStorage is easier for cross-origin APIs and is fine only if you are confident about XSS, which most teams should not be. Neither choice matters if the token lives for hours; short expiry does more for you than the storage decision.

Tools used in this guide

Every one of these runs in your browser — the files you work on never leave your device.

More developer guides