9 min read1,712 words

Decoding a JWT Safely Without Uploading It

A three-part token strip with a key and magnifying glass

A JWT looks like an unreadable string, but it is only base64url-encoded JSON, not encrypted, and decoding it is one of the safer debugging steps a developer can take, provided it happens locally. This post covers the structure of a token, what the standard claims mean, and the important distinction between decoding and verifying. The JWT decoder under /dev-tools does this entirely client-side, so nothing in the token leaves your browser.

jwtsecurityencoding
Share on XHacker News

The three segments

A JWT is three base64url-encoded parts joined by dots: header, payload and signature. The header typically states the signing algorithm and the token type. The payload holds the claims, which are the actual data the token carries, such as who it identifies and when it expires. The signature is computed over the header and payload and lets a party holding the correct key verify that neither part has been altered since signing.

Splitting a JWT on the dot character and decoding the first two segments gives you the header and payload as readable JSON. The signature segment is not meant to be human-readable; it is a cryptographic value, not encoded data, and decoding it does not produce anything meaningful.

Base64url, not base64

JWTs use base64url encoding rather than standard base64. The difference is small but matters: base64url replaces + with -, replaces / with _, and typically omits the trailing = padding characters. This is done specifically so the encoded string is safe to use inside a URL or an HTTP header without needing further escaping.

A decoder that only understands standard base64 will fail or produce garbage on a real JWT, so any tool handling tokens needs to translate the alphabet back before decoding, and re-add padding if the underlying decode function requires it.

Standard claims

A handful of claim names are standardised and appear across most JWT implementations. iss identifies the issuer, sub identifies the subject, typically a user ID, aud identifies the intended audience, exp is the expiry time as a Unix timestamp, nbf is a "not before" time, and iat is the time the token was issued. Anything beyond these is application-specific and defined by whoever issues the token.

None of these claims are protected from viewing just by being inside a JWT. Anyone who has the token, including a user inspecting their own browser storage, can read every claim in the payload. A JWT should never be used to carry information that the holder is not meant to see, even if the application never displays it.

  • iss: issuer
  • sub: subject, typically a user identifier
  • aud: intended audience
  • exp, nbf, iat: expiry, not-before and issued-at timestamps

Decoding is not verification

Reading the header and payload of a JWT tells you what the token claims, but it tells you nothing about whether those claims are genuine. Verification means recomputing the signature using the correct key and algorithm and confirming it matches the signature segment on the token. A token with a modified payload will decode just as readily as a legitimate one; the modification only becomes apparent once you check the signature.

This distinction matters most in code: never treat a decoded payload as trustworthy input on a server without verifying the signature first. Decoding without verifying is fine for a developer inspecting a token during debugging, since the goal there is to read the claims, not to authenticate anything.

Signature algorithms and the alg none pitfall

The header field alg names the signing algorithm, common examples being HS256 for an HMAC with SHA-256 and a shared secret, and RS256 for RSA with SHA-256 and a public and private key pair. A server verifying a token should specify which algorithms it accepts rather than trusting the alg value in the token itself.

A well-known vulnerability class comes from libraries that trusted the alg field blindly and accepted a value of none, which by specification indicates an unsigned token with no signature at all. An attacker could set alg to none, strip the signature, and have a forged token accepted. Modern libraries reject this by default, but it is a good example of why verification logic needs to pin the expected algorithm rather than deferring to whatever the token claims about itself.

Expiry and clock skew

The exp claim is a Unix timestamp, and a token is considered expired once the current time passes it. Because clocks on different machines are rarely perfectly synchronised, most verification libraries allow a small amount of clock skew tolerance, commonly a few seconds to a couple of minutes, so a token is not rejected purely because of a minor time difference between the issuer and the verifier.

When debugging an "invalid token" error, checking exp against the current time is one of the first useful steps, since an expired token produces the same kind of rejection as a genuinely invalid signature in many client libraries, and the two are easy to confuse without decoding the payload first.

Never paste production tokens into remote tools

A JWT is not encrypted, so pasting one into any online decoder exposes every claim inside it, including user identifiers, roles, and any custom data the issuer chose to include, to whatever server that tool sends the input to. Even if the tool only decodes and never stores anything, you have no way to verify that from outside, and a compromised or malicious tool could log every token it receives.

The safe habit is to only decode tokens using something that runs entirely in your own browser, with no network request involved in the decode step, and to treat any production token as sensitive data regardless of whether the payload looks harmless.

Decoding locally

A JWT decoder built to run client-side needs nothing more than the base64url alphabet translation and a JSON.parse call on each of the first two segments, which is a small enough operation to run instantly in a browser tab with no server round trip. The JWT tool under /dev-tools does exactly this, splitting the token, decoding the header and payload, and leaving the signature untouched since verifying it correctly requires the actual signing key, which the tool never has and never should ask for.

Worked example: decoding a token by hand

Take a token like eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0IiwiZXhwIjoxNzAwMDAwMDAwfQ.signature. Split on the dots to get three segments. Decoding the first segment as base64url gives {"alg":"HS256"}, showing the algorithm. Decoding the second gives {"sub":"1234","exp":1700000000}, showing the subject and expiry as a Unix timestamp, which converts to a specific date and time in UTC using any standard date library.

Note that the payload here has no aud or iss claim at all, which is perfectly valid; the standard claims are optional conventions, not requirements, and an issuer can include only the claims relevant to its own use case alongside any custom fields it needs.

Edge cases when handling tokens

A token with unpadded base64url segments needs padding re-added before some decoding functions will accept it, since the standard atob function expects padding while base64url conventionally omits it; a robust decoder pads the string out to a multiple of four characters with = before decoding. A token containing non-ASCII characters in its claims, such as a name with accented letters, requires decoding the payload bytes as UTF-8 rather than treating each byte as a single Latin-1 character, or the displayed text comes out garbled even though the underlying claim value was correct.

A malformed token, one with the wrong number of dot-separated segments or invalid base64url content, should fail with a clear error rather than partially decoding and showing misleading data. Refresh tokens and access tokens are sometimes opaque strings rather than JWTs at all, and attempting to decode an opaque token as if it were a JWT will simply fail, which is expected rather than a sign of a broken tool.

  • Unpadded base64url needs padding restored before some decode functions accept it
  • Decode claim bytes as UTF-8, not Latin-1, or accented characters render incorrectly
  • A token with the wrong number of dot-separated segments is not a JWT and should fail clearly
  • Not every token is a JWT; opaque access tokens and refresh tokens are common alternatives

Debugging checklist for token issues

When an API rejects a token as invalid, first decode the payload and check exp against the current time, since expiry is the single most common cause and produces the same generic error as a bad signature in many client libraries. Second, confirm the algorithm in the header matches what the verifying server expects, since a token signed with one algorithm will not verify against code expecting another, even with the correct key.

Third, check for whitespace or line breaks accidentally included when the token was copied from a log file or an environment variable, since a single stray newline character at the end of a token string will make an otherwise valid signature fail to verify. Fourth, confirm the signing key or secret used for verification actually matches the one used to issue the token, particularly after a key rotation, since an old token cannot be verified against a newly rotated key.

  • Check exp against current time first; expiry is the most common failure
  • Confirm the alg in the header matches what the verifier expects
  • Watch for stray whitespace or newlines introduced when copying a token
  • Confirm the verification key matches the one that actually signed the token, especially after rotation

FAQ

Can I edit a decoded JWT and use it? Editing the payload and re-encoding it produces a token with a signature that no longer matches, so a server verifying signatures will reject it. This is intentional; a JWT decoder is a read tool, not a way to forge access.

Are JWTs encrypted? No, standard JWTs (JWS) are signed, not encrypted, meaning anyone can read the claims but cannot forge a valid signature without the key. A separate, less common format called JWE does provide actual encryption of the payload, and requires the correct decryption key to read the contents at all.

Why does my token have extra fields I did not expect? Many frameworks and identity providers add their own standard-adjacent claims automatically, such as session identifiers or scope lists, so an unfamiliar claim is not necessarily a bug, though it is worth checking the issuing service documentation to confirm what it represents.

Questions about the tools in this guide

Short answers about the hubs this article touches, each linking straight to the tool.

Dev Tools

Open hub