Toolium

JWT Tokens, Decoded: What Is Really Inside Your Auth Token

Hemanth Gedda9 min read

Almost every modern auth system you have used since about 2015 hands your browser a JWT (JSON Web Token) at login. Auth0, Clerk, Supabase, Firebase Auth, AWS Cognito, your own homegrown auth, the OAuth flow you set up for a side project last weekend - all of them use JWTs as the unit of identity. Despite being everywhere, JWTs are also widely misunderstood. People think they are encrypted (they are not). People think the signature protects the payload from being read (it does not). People think you need a backend roundtrip to inspect a token (you do not). This post is the explanation I would have wanted when I was building auth into a project for the first time.

I will use the Toolium JWT Decoder as the running example. But everything in this post is observable using any decoder, including the command line. Throughout, I am going to use the word "claim" to mean a key-value pair inside the JWT - "sub: user_123" is one claim. Standard terminology.

The three sections

A JWT is a string with two dots in it: aaaa.bbbb.cccc. The three sections are:

  • Header (aaaa): Base64url-encoded JSON describing the token type and the signing algorithm. Typically two keys: alg (the algorithm, e.g. "HS256" or "RS256") and typ (the token type, almost always "JWT").
  • Payload (bbbb): Base64url-encoded JSON containing the claims about the user. This is where things like user ID, email, expiration timestamp, and any custom claims live.
  • Signature (cccc): Base64url-encoded bytes that are the cryptographic signature over the header and payload, produced using the algorithm declared in the header and a secret key (or private key, for asymmetric algorithms).

To decode a JWT, you split on the dots and base64url-decode each section. The header and payload give you readable JSON. The signature, when decoded, is raw bytes that look like binary garbage; you cannot meaningfully look at it.

The single most important thing to understand

The payload is not encrypted. It is encoded.

I cannot count the number of times I have seen developers store something they think is private in a JWT payload because "it is signed." Signing protects the payload from tampering, not from reading. Any user holding a JWT can paste it into any decoder and read every claim it contains. The signature only verifies that the token was created by the legitimate issuer and has not been modified since.

This means: never put a password, an API key, a credit card number, or any other genuinely secret value in a JWT payload. The user can read it. If a JWT ever leaks (and they do leak, through browser history, server logs, error reports), the secrets in it are exposed.

The right shape for claims is "identity attributes" - user ID, email, roles, permissions. None of these are secret in the security sense; the user already knows who they are.

How verification works (and why your server still needs the secret)

When the server hands a user a JWT, the flow is roughly:

  1. Server builds a header object and a payload object.
  2. Server base64url-encodes both, joins them with a dot.
  3. Server computes the signature over that joined string using its secret key (for HS256) or private key (for RS256), then base64url-encodes the signature.
  4. Server appends the signature with another dot. That's the JWT.

When the user sends the JWT back on a future request (usually in an Authorization header), the server verifies:

  1. Split the JWT on dots.
  2. Recompute the signature over the first two sections using the same key.
  3. Compare the recomputed signature to the one sent in the third section.
  4. If they match, the token is valid - it was issued by the server (or by someone with access to the same key) and has not been modified.

The asymmetric variants (RS256, ES256) make this nicer for distributed systems: the issuer signs with a private key, and any service can verify with the public key. The verification side never needs the private key. This is how OAuth flows work across multiple services.

The alg:none vulnerability

This was a big deal when it was disclosed in 2015 and is worth understanding because it is illustrative of how JWT goes wrong.

The JWT specification allows an algorithm called "none" - meaning the token is unsigned. The original intent was to support cases where signing was handled at a different layer (TLS, for example). But many JWT libraries implemented the verification step as: read the alg field from the header, look up the verification function, call it. If the header says alg:none, the verification function is "no-op, return success."

The exploit: an attacker takes a legitimate JWT, modifies the payload to claim admin privileges, sets the alg field to "none," and sends it. Vulnerable servers accept it because the no-op verification succeeds.

The fix - which every JWT library now implements - is to never allow alg:none unless the application explicitly opts in, and to validate the alg field against an allow-list. If your code is using a current JWT library (jose, jsonwebtoken, pyjwt), you are fine. If you wrote a homegrown verifier, audit it.

The Toolium decoder shows the alg field prominently and warns when it is "none," because that is almost always a red flag.

The standard claims you will see

The JWT spec defines a small set of standard claims. They are all three-letter abbreviations for historical reasons:

  • iss (issuer) - who created the token. Usually a URL like "https://auth.example.com".
  • sub (subject) - who the token is about. Usually the user ID.
  • aud (audience) - who the token is intended for. Usually a service identifier like "api.example.com".
  • exp (expiration) - Unix timestamp after which the token is no longer valid. The most important claim from a security perspective.
  • iat (issued at) - Unix timestamp when the token was created.
  • nbf (not before) - Unix timestamp before which the token is not valid. Used for delayed activation.
  • jti (JWT ID) - unique identifier for the token. Useful for revocation lists.

Beyond these, custom claims are allowed and common. Auth0 puts the user's email at https://example.com/email. Clerk puts metadata under public_metadata. Conventions vary by issuer.

What I check when I see a JWT in production

If a teammate sends me a JWT and asks "why isn't this working," the things I check, in order:

  1. Is it expired? Decode it, look at the exp claim, compare to current time. Most "auth not working" bugs are stale tokens.
  2. What does the alg field say? If it is "none," something is very wrong. If it does not match what the verifying service expects, that is also wrong.
  3. What is the iss claim? Is it from the issuer you expect? Tokens from a development environment sent to production almost always fail this.
  4. What is the aud claim? If your service expects to receive tokens for "api.prod" and the aud says "api.staging," that is the bug.
  5. Does the sub claim correspond to a real user in your database? If not, the user might have been deleted or the token might be forged.

The Toolium decoder shows all of these in a readable format, with the timestamps already converted to human-readable dates so you do not have to do the Unix-time arithmetic in your head.

Privacy of decoding

The decoder runs in your browser. If you paste a production JWT to debug a problem, the token does not leave your computer. This matters because JWTs are credentials - if I were to log them server-side, I would have an access-token database, which is exactly the thing you do not want anyone else having. The Toolium decoder cannot log what it does not receive.

That said, JWTs are intended for the holder, not for sharing. If you are debugging someone else's JWT, you have the same access as them. Be thoughtful about whose tokens you paste anywhere, including local tools.

What the decoder does not do

  • Verify the signature. Verification requires the secret key (for HS256) or the public key (for RS256/ES256), and pasting your secret key into a web tool would be wildly insecure. The decoder only decodes the header and payload; it shows you what the token claims without confirming the claims are legitimate.
  • Re-sign a modified token. Same reason - that needs the key. If you are testing changes locally, use a command-line tool like jwt-cli where you control the key.
  • Encrypted JWTs (JWE). JWE is a different specification where the payload itself is encrypted. The Toolium decoder does not currently handle JWE; for those, use a server-side library with the correct decryption key.

Try the tool mentioned in this article

Open tool