developer · · Updated

What Is a JWT and How to Decode One Safely

By

A JSON Web Token looks like one long random string, but two of its three parts are plain JSON that anyone can read. Taking a real token apart by hand is the quickest way to see what a decoder does, what it can't do, and where the common mistakes come from.

A token, taken apart

This is the sample token the JWT Decoder opens with, split at its two dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNzM2OTM2ODAwLCJleHAiOjE3Njg0NzI4MDB9
.
dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U

Each part is Base64URL, a variant of Base64 that uses - and _ instead of + and / and drops the = padding, so a token can sit in a URL without escaping. Decoding the first two parts gives JSON:

{"alg":"HS256","typ":"JWT"}
{"sub":"1234567890","name":"John Doe","iat":1736936800,"exp":1768472800}

The third part is not JSON. It is the signature: 43 Base64URL characters that decode to 32 raw bytes, the output size of HMAC-SHA256, which the header's HS256 names.

Three details a hand-rolled decoder gets wrong

  • Padding. Standard Base64 decoders expect the length to be a multiple of 4. The header and payload above happen to be (36 and 96 characters), but a payload such as {"sub":"42","name":"Zoë"} encodes to 35 characters and needs one = added back before atob will accept it.
  • Non-English text. atob returns one character per byte, so that same payload comes out as "name":"Zoë". The bytes have to be decoded as UTF-8 to get Zoë back. The decoder on this site does both steps.
  • Five parts instead of three. An encrypted token (JWE) has five dot-separated parts, and its payload is ciphertext. A decoder can't read it without the key, so the JWT Decoder stops with "A JWT must have three parts" rather than showing garbage.

What the time claims say

iat (issued at), exp (expires) and nbf (not before) are counted in seconds since 1 January 1970, UTC (RFC 7519 calls this a NumericDate). In the sample:

  • iat 1736936800 is 15 January 2025, 10:26:40 UTC.
  • exp 1768472800 is 15 January 2026, 10:26:40 UTC, exactly 365 days later.

So this token has expired, and the decoder now says so next to the date, judged by your device's clock. A server compares with its own clock and usually allows a little leeway for clock skew, so a token can be rejected a minute or two before the time you see, or accepted just after it.

A common bug is writing the time in milliseconds. exp: 1768472800000 isn't rejected by any parser; it just means a date in the year 58010, which never expires. The decoder flags any time value that large.

Decoding is not verifying

Nothing above needed a secret. Anyone holding a token can read it, which is why a payload should never contain a password, an API key, or personal data the user shouldn't see. What the secret (for HS256) or the public key (for RS256 and ES256) is needed for is the signature: proof that the server issued this exact header and payload.

The best-known trap is the header {"alg":"none"} with an empty third part. It decodes normally, because decoding doesn't care, and some early libraries accepted it as a valid, unsigned token. RFC 8725, the JWT best-practices document, tells libraries to let the application fix the list of accepted algorithms and never switch based on the token's own header. When your code verifies a token, pass the algorithm explicitly rather than trusting alg.

Where it is safe to paste a token

A production token is a credential until it expires: whoever has it can act as that user. Decoding it on a site that sends it to a server hands the credential to that server. The JWT Decoder decodes in your browser and makes no network request with the token. It is still good practice to decode a token that has already expired, or one from a test environment, when you're only debugging the claims.

Tokens often carry IDs generated as UUIDs; the UUID guide covers why, and which version to use.

Related guides