What a JWT actually is

A JSON Web Token is three base64url-encoded segments joined by dots: header.payload.signature. The header describes how the token is signed (the algorithm and an optional key id). The payload carries the claims — who the subject is, when the token expires, who issued it. The signature is computed over the first two segments using a key.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   <- header
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6...  <- payload
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_...   <- signature

The critical thing to internalize: base64url is encoding, not encryption. The payload is not secret. Anyone holding the token can decode it back to plaintext JSON in a single line of code, read every claim, and — just as easily — change a claim and re-encode it. A standard JWT offers no confidentiality at all.

Mental model: base64url is a transport wrapper, like putting a letter in a clear envelope. It survives URLs and HTTP headers cleanly, but everyone can read what is inside.

Decoding is not verification

This is the single most common misunderstanding about JWTs. When you decode a token you learn what it claims. You learn nothing about whether those claims are true or whether the token was issued by anyone you trust.

The signature is the only trust boundary. Because the payload is just encoded text, an attacker can take a valid token, flip "role": "user" to "role": "admin", base64url-encode the modified payload, and hand it back. Decoding that tampered token will happily show admin. It looks completely legitimate.

The only thing that catches the forgery is recomputing the signature with the correct key and comparing it to the signature on the token. If the attacker does not hold the signing key, they cannot produce a matching signature, and verification fails. Decoding answers "what does this say?"; verification answers "should I believe it?" — and only the second question matters for an access decision.

How verification really works

Verification recomputes (or cryptographically checks) the signature over header.payload and rejects the token if it does not match. How the key works depends on the algorithm family.

HS256 — symmetric (HMAC)

HMAC algorithms (HS256, HS384, HS512) use one shared secret for both signing and verifying. Whoever can verify can also forge, so the secret must never leave the server. This is fine for a single backend that both issues and checks its own tokens; it is a poor fit when many parties need to verify but should not be able to mint tokens.

RS256 / ES256 — asymmetric (public/private key)

RSA and ECDSA algorithms sign with a private key and verify with the corresponding public key. The issuer keeps the private key; verifiers only need the public key, which is safe to distribute — commonly via a JWKS endpoint (/.well-known/jwks.json) keyed by the header's kid. This is what identity providers use, because a thousand services can verify tokens without any of them being able to issue one.

Regardless of algorithm, verification belongs server-side, on every protected request. Verifying the signature is necessary but not sufficient — you must also validate the standard claims:

  • exp — reject expired tokens.
  • nbf — reject tokens used before their "not before" time.
  • iat — sanity-check the issued-at time; optionally enforce a maximum age.
  • aud — confirm the token was meant for your service, not a sibling API.
  • iss — confirm it came from the issuer you expect.
  • and pin the expected alg — see the next section for why this one is not optional.

alg:none and algorithm confusion

Two classic attacks exploit libraries that trust the token's own header to decide how to verify it. Both are defeated by the same rule: the server decides the algorithm, never the token.

The alg:none downgrade

The JWT spec defines a "none" algorithm meaning "unsigned." An attacker sets the header to {"alg":"none"}, writes whatever payload they want, and leaves the signature segment empty. A naive verifier reads alg from the header, sees "none," concludes no signature is required, and accepts the forged token. The fix is to never honor "none" for tokens that are supposed to be signed.

// forged header an attacker can send
{ "alg": "none", "typ": "JWT" }
// payload they control, no signature needed
{ "sub": "attacker", "role": "admin" }

RS256 to HS256 confusion

If your server expects RS256 and verifies with the RSA public key, but the library picks the algorithm from the token header, an attacker can set alg to HS256 and sign the token using your public key as the HMAC secret. Your public key is, by definition, public — so the attacker can compute a valid HMAC, and the verifier (using that same public key as the HS256 key) accepts it.

Defense: always allowlist the exact algorithm(s) you expect (for example, only RS256) and pass that allowlist into your verification call. Do not let the token's header choose the verification path.

Never paste production tokens into random websites

A JWT is a bearer credential. For as long as it is valid, whoever holds it can act as the subject — that is the whole point of a bearer token. Pasting a live token into a server-side "JWT decoder" can put that credential in someone else's request logs, error tracking, or analytics, where it can be replayed until it expires.

OnlineDevTools' JWT Decoder runs entirely in your browser: the token is decoded with local JavaScript and is not uploaded to any server. That removes the server-log risk for this tool specifically. Even so, treat live tokens carefully — browser extensions, shared screens, and clipboard history are all real exposure paths — and prefer decoding expired or sample tokens whenever you only need to inspect structure.

When you just want to see what a token looks like, use this well-known public example. It is published in JWT documentation everywhere; it is not a real credential and grants access to nothing:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Decoding it shows a header of {"alg":"HS256","typ":"JWT"} and a payload of {"sub":"1234567890","name":"John Doe","iat":1516239022}. That is enough to learn the format without ever touching a production secret.

What to check when you decode

When you do inspect a token, read the claims with intent. Each one tells you something specific:

  • alg (header) — the signing algorithm. Confirm it matches what your system expects; be suspicious of "none."
  • kid (header) — key id, used to pick the right public key from a JWKS set.
  • iss — the issuer. Should be an identity provider you trust.
  • sub — the subject, usually the user or service identity the token represents.
  • aud — the intended audience; the service the token is meant for.
  • exp — expiry as a Unix timestamp; a token past this should be rejected.
  • nbf — "not before" time; the token is invalid earlier than this.
  • iat — issued-at time; useful for spotting unusually old tokens.
  • jti — a unique token id, used to support revocation or replay detection.
  • custom claims (e.g. role, scope, email) — application data; never trust these without verifying the signature first.

Related tools

  • JWT Decoder — inspect a token's header and payload locally in your browser
  • Base64 Encoder / Decoder — decode the individual base64url segments by hand
  • Hash Generator — compute HMAC and digest values for testing signatures
  • Secure Paste — share sensitive snippets more carefully than a public pastebin
  • CSP Analyzer — review Content-Security-Policy headers for your app

Related guides