JWT Decoder
How it works
Why atob() throws InvalidCharacterError on a JWT
JWT segments are base64url (RFC 7515), not standard Base64: '+' becomes '-', '/' becomes '_', and the '=' padding is dropped. atob() actually tolerates the missing padding, but it rejects '-' and '_' with an InvalidCharacterError DOMException - the example token's signature segment contains '_', so atob() throws on it. Stricter decoders in other languages fail the other way: the example payload segment is 74 characters (length % 4 == 2), so Python's base64.b64decode raises until '==' is restored. This tool swaps the alphabet back and re-pads each segment, then decodes the bytes as UTF-8.
exp, iat and nbf are unix seconds, not milliseconds
RFC 7519 defines these claims as NumericDate: seconds since 1970-01-01T00:00:00Z. Two bugs follow from mixing that up with JavaScript's milliseconds. Reading: new Date(1516239022) is 1970-01-18T13:10:39Z, which is why a decoded token seems to say your session started in 1970 - it needs new Date(1516239022 * 1000), which is 2018-01-18T01:30:22Z. Minting: signing with exp: Date.now() produces something like 1782864000000, which read as seconds is the year 58466, so the token effectively never expires and every verifier accepts it forever. This tool prints each time claim raw and as UTC, and flags values that look like milliseconds.
Decoding is not verifying
Everything on this page comes from base64url-decoding two JSON blobs. No cryptography runs, and nothing here proves the token is genuine: anyone can mint a token with any claims, and it will decode exactly like a real one. Header and payload are attacker-controlled text until a server checks the signature against a key. That is also why a verifier must never trust the header to pick the algorithm: honoring alg "none" turns verification off entirely, and letting a token downgrade RS256 to HS256 can trick a verifier into using the RSA public key as an HMAC secret. If the header lists "crit" extensions, a verifier must reject the token unless it understands every one of them. This page never says "valid" - it cannot know.
Examples
| eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c | {"sub":"1234567890","name":"John Doe","iat":1516239022} |
| eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwiaWF0IjoxNzgyODY0MDAwLCJuYmYiOjE3ODI4NjQwMDAsImV4cCI6MTc4Mjg2NzYwMH0.f-5gEdgCy5egFyp7sHMAUGOTBExmNNfIt9YHmCAccJc | exp 1782867600 2026-07-01T01:00:00Z (expires) |
| eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9. | {"sub":"admin"} |
FAQ
Does this tool tell me whether my JWT is valid?
No, and it never will. It base64url-decodes the header and payload; the signature is never checked, so nothing here proves authenticity - a forged token decodes exactly like a real one. Validity requires verifying the signature against the signing key, which belongs on the server.
Why does atob() throw InvalidCharacterError on my JWT?
JWT segments are base64url: '-' and '_' replace '+' and '/', and the '=' padding is dropped. atob() rejects '-' and '_' outright (missing padding it actually tolerates), and strict decoders in other languages reject the missing padding. Swap the characters back and pad to a multiple of 4 - this decoder does both per segment.
Why does my token's expiry show January 1970?
exp is unix seconds, but JavaScript's Date expects milliseconds. new Date(1516239022) is 1970-01-18T13:10:39Z; new Date(1516239022 * 1000) is the intended 2018-01-18T01:30:22Z. Multiply by 1000 before constructing the Date.
Why does my token never expire (exp in the year 58466)?
It was minted with Date.now(), which returns milliseconds, where RFC 7519 NumericDate is seconds. 1782864000000 read as seconds is +058466-09-17, so every verifier sees a far-future expiry. Sign with Math.floor(Date.now() / 1000) plus the lifetime instead. This tool flags time claims that look like milliseconds.
What does alg "none" mean?
RFC 7518 defines "none" as an unsecured JWS: no signature at all, an empty third segment. A verifier that honors the header's alg on such a token performs no check, so anyone can forge any claims. Libraries must reject "none" unless it was explicitly enabled; this tool decodes such tokens but warns.
Is my token uploaded anywhere?
No - and with a JWT that matters, because it is a live credential. The page ships a strict Content-Security-Policy: connect-src 'none' blocks fetch, XHR, WebSockets and beacons, while default-src 'self' and form-action 'none' block every other way markup could send data off-site. Decoding happens in your browser and cannot leave it.