Skip to main content

JWT Decoder

Paste a JWT to inspect header and payload; no secret needed for decoding.

Signature is not verified in this tool. For verification, use your public key and verify on the server.

Split a token on its dots and read the header and payload. Decoding tells you what a token claims; it says nothing about whether those claims are true, and the gap between those two operations is where nearly every JWT vulnerability lives. This page covers what to check before trusting anything shown here.

Decoding is not verifying, and the header is attacker-controlled

Every JWT library ships two functions that look interchangeable and are not. In Node's jsonwebtoken, decode() parses the token and hands back the claims without touching the signature, while verify() checks the signature and the time-based claims first. Calling decode() on a request path and acting on the result is a complete authentication bypass: the attacker writes whatever payload they like and needs no key at all.

Two classic attacks target the verification step itself, and both exploit the same seam — alg lives in the header, which is unsigned and fully attacker-controlled. Setting it to none declares an unsecured token, and a verifier that reads the algorithm from the header and dispatches on it will accept an empty signature. The subtler variant is RS256-to-HS256 confusion: the attacker rewrites the header to claim HMAC and signs the token using the RSA public key as the shared secret. Since the server passes that same public key into its verify call, the HMAC check succeeds. The jsonwebtoken package was vulnerable to exactly this before version 4.2.2.

The fix in both cases is to pin the algorithm server-side instead of reading it from the token — jwt.verify(token, key, { algorithms: ["RS256"] }) — and never to place a symmetric and an asymmetric algorithm in the same allowlist. RFC 8725, the JWT best-current-practices document, states this along with the related rule that jku, x5u, and embedded jwk headers point at attacker-supplied key material and must never be fetched blindly.

Everything in the payload is readable by whoever holds the token

A JWT is signed, not encrypted. The middle segment is base64url, an encoding rather than a cipher, so anyone who obtains the token reads every claim in it: the user whose browser stores it, whatever proxy logged the Authorization header, whatever error tracker captured the failing request. That is a property of the format, not a misconfiguration to fix.

The payload is therefore the wrong home for internal database identifiers you would rather not expose, email addresses and phone numbers covered by data-protection rules, permission structures that reveal which other roles exist, or anything resembling a key or a secret. The signature guarantees integrity — nobody alters a claim without invalidating it — and offers precisely zero confidentiality.

Where a claim genuinely must stay private, the JOSE family provides JWE (RFC 7516) for encrypted tokens, though the more common answer is to keep the sensitive value server-side and carry an opaque reference instead. Remember too that tokens escape through channels you do not control: a JWT placed in a query string lands in server access logs, browser history, and the Referer header sent to third-party origins.

The claims that decide whether a token should be accepted

RFC 7519 registers seven claims, and a verifier that only checks the signature has checked about half of what matters. exp and nbf bound the validity window, iat records issuance, iss identifies who minted the token, aud identifies who it was minted for, sub identifies the principal, and jti gives it a unique identifier.

aud is the claim most often skipped and the one whose absence causes the most damage. When three internal services all trust tokens from the same identity provider and none of them check the audience, a token issued for the low-privilege service is replayed verbatim against the high-privilege one and accepted. Checking iss becomes equally important the moment more than one provider is in play.

The time claims have a format that trips people constantly. RFC 7519 defines NumericDate as seconds since the Unix epoch, whereas Date.now() in JavaScript and System.currentTimeMillis() in Java both return milliseconds. Pass one straight through and you mint a token expiring tens of thousands of years out; make the inverse mistake on nbf and every token is rejected as not-yet-valid. Servers also disagree about the current time, so the spec permits small leeway — usually no more than a few minutes — and a fleet without working NTP produces intermittent expiry failures that look like anything except a clock problem.

You cannot revoke a token nobody is tracking

Statelessness is the reason to adopt JWTs and the reason they are hard to take back. The server checks a signature and reads an expiry; it holds no record of the token, so there is nothing to delete. A token lifted off a laptop stays valid until exp passes, and logging out, changing a password, or disabling the account does nothing to it unless you built something that does.

The usual mitigation keeps access-token lifetimes short — minutes rather than days — and pairs them with a long-lived refresh token that is stored server-side and can be deleted. That caps exposure at the access-token lifetime and moves the revocation decision to the refresh boundary. A denylist keyed on jti gives immediate revocation instead, at the price of a lookup on every request, which reintroduces the shared state JWTs were adopted to avoid.

A cheaper middle ground is a per-user token version, or a "credentials changed at" timestamp compared against iat: one indexed read, invalidates every outstanding token for that user at once, and costs nothing until someone uses it. Whichever you pick, decide before launch. Retrofitting revocation onto a deployed stateless system means a forced logout for the entire user base.

base64url, absent padding, and the shape of the wire format

A signed token is three base64url segments joined by dots. Base64url is the RFC 4648 section 5 alphabet, which swaps + for - and / for _ so the value survives a query string and a filename, and JWS additionally requires the = padding to be stripped, since an = would be percent-encoded to %3D in a URL.

Both differences bite when decoding a segment by hand. In a browser, atob() throws InvalidCharacterError on any segment containing - or _, because it implements the standard alphabet only. It happens to tolerate the missing padding, but Python's base64.b64decode does not and raises binascii.Error: Incorrect padding. Translating by hand means swapping those two characters back and appending = until the length is a multiple of four — and a remainder of one is not under-padding, it is corruption.

A few structural details are worth recognising. An unsecured token keeps its trailing dot with an empty third segment. Tokens grow with their claims, and one stored in a cookie competes with the roughly 4 KB per-cookie limit browsers enforce, so a token carrying a large permission list can quietly exceed it and stop being sent. To inspect a single segment outside a token context, the Base64 decoder handles it once the alphabet is translated.

ThenCatch is a free developer toolkit. Learn more about us.