Decoding is not verification
This distinction is the whole reason to read past the first paragraph. Decoding a JWT tells you what the token *claims*. Verifying it tells you whether those claims came from the issuer you trust and have not been altered. They are entirely separate operations, and only the second one requires a secret.
The payload is base64url, not encryption. Anyone holding the token — including any proxy, any log aggregator that captured the Authorization header, and anyone with access to the browser it is stored in — can read every claim in it. That is exactly what is happening on this page. The consequence for your own systems is simple: never put anything confidential in a JWT payload. No passwords, no card numbers, no internal database rows you would not show the user. If the token needs to carry secrets, you want JWE (an encrypted token), not JWS.
"admin": true proves nothing — anyone can craft that payload. Only a signature check against the issuer's key makes a claim trustworthy.The three segments
A signed token (JWS) has the form header.payload.signature. The header is a JSON object declaring the algorithm (alg) and type (typ), and often a key ID (kid) telling the verifier which of the issuer's keys was used. The payload is a JSON object of claims. The signature covers the base64url encoding of the first two segments joined by a dot — which is why you cannot change a single character of the payload without invalidating it.
Read the full explanation of jwt decoder results ↓Show less ↑
If you paste something with five dot-separated segments, that is a JWE — an encrypted token whose payload cannot be read at all without the decryption key. This tool will tell you so rather than producing nonsense. Two segments with an empty third means an unsigned token, which is a red flag on anything that matters.
The registered claims
| Claim | Name | What it means |
|---|---|---|
iss | Issuer | Who minted the token. A verifier must check this, not just the signature. |
sub | Subject | Who the token is about — usually a stable user ID, not an email. |
aud | Audience | Who the token is *for*. Rejecting a token meant for a different service is a required check. |
exp | Expiration | NumericDate after which the token must be rejected. |
nbf | Not before | NumericDate before which the token must be rejected. |
iat | Issued at | When it was minted. Useful for detecting a token older than a policy allows. |
jti | JWT ID | Unique identifier, used for replay detection and revocation lists. |
All three time claims are NumericDate: seconds since 1970-01-01 UTC, not milliseconds. This trips people up constantly, because JavaScript's Date.now() returns milliseconds. Signing a token with exp: Date.now() + 3600000 produces a token that expires in about the year 56,000; signing with a millisecond value where seconds are expected fails validation immediately on some libraries and passes absurdly on others. This decoder interprets them as seconds, per RFC 7519 §2, and shows both the local time and the ISO form so a wrong-by-1000 value is immediately obvious.
A missing exp deserves attention. A token with no expiry is valid forever unless the issuer maintains a revocation list — and the main appeal of JWTs is that they are stateless, meaning most issuers do not. That combination is how a leaked token stays useful for years.
Reading the alg field
HS256 is HMAC-SHA-256: one shared secret both signs and verifies. Fine when a single service issues and consumes its own tokens; unworkable when you need to distribute verification, because every verifier would also be able to mint tokens. RS256 and ES256 are asymmetric — the issuer signs with a private key and anyone can verify with the public key, which is what OIDC providers publish at a JWKS endpoint.
alg: "none" means the token asserts it is unsigned. It exists in the specification for tokens whose integrity is guaranteed by other means, and it has been the basis of a long series of real vulnerabilities: a library that reads the algorithm from the header and dispatches on it will happily "verify" a none token that an attacker rewrote. The related attack swaps RS256 for HS256 and signs with the *public* key as the HMAC secret. The defence for both is the same — the verifier must pin the algorithm it expects and refuse anything else, never trust the header to choose.
This tool flags alg: none explicitly because seeing it in a token from a production system is worth investigating immediately.
Verifying properly, on the command line
Decoding is a one-liner. Verification needs the key, and is worth doing with a real library rather than by hand — but the manual HMAC check below is instructive because it shows exactly what the signature covers.
JWT='eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature'
# Decode the header and payload (base64url -> base64, then decode)
echo "$JWT" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
echo "$JWT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
# Is it expired? Compare exp against now, in seconds
echo "$JWT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
| jq --argjson now "$(date +%s)" '{exp, now: $now, expired: (.exp < $now)}'
# Verify an HS256 signature by hand — the input is header.payload, verbatim
SIGNING_INPUT=$(echo "$JWT" | cut -d. -f1,2)
printf '%s' "$SIGNING_INPUT" \
| openssl dgst -binary -sha256 -hmac 'your-shared-secret' \
| openssl base64 -A | tr '+/' '-_' | tr -d '='
# Compare that to: echo "$JWT" | cut -d. -f3
# Fetch an issuer's public keys for RS256/ES256 verification
curl -s https://accounts.google.com/.well-known/openid-configuration | jq -r .jwks_uri
curl -s "$(curl -s https://accounts.google.com/.well-known/openid-configuration | jq -r .jwks_uri)" | jq .Note that the signing input is the raw header.payload string exactly as it appears in the token — not a re-encoding of the decoded JSON. Re-serialising the payload changes key order and whitespace and produces a different signature, which is why verification must operate on the original bytes.
A verifier's checklist
- Pin the expected algorithm. Never dispatch on the token's own
algheader. - Verify the signature before reading any claim. A payload from an unverified token is attacker-controlled input.
- Check
expandnbf, with a small clock-skew allowance (60 seconds is conventional). - Check
issagainst the exact issuer you expect, andaudagainst your own service identifier. A valid token for a different audience is not a valid token for you. - For asymmetric tokens, fetch keys from the issuer's JWKS endpoint and match on
kid. Cache them, and handle key rotation — a hardcoded public key becomes an outage on rotation day. - Keep token lifetimes short. Statelessness means you generally cannot revoke, so expiry is your only real control.
- Store tokens where XSS cannot read them where possible.
localStorageis readable by any script on the page; anHttpOnlycookie is not.
If a token in your hands is failing verification and you cannot see why, decode it here first. Roughly half of all JWT bugs turn out to be visible in the payload alone: a millisecond exp, an aud naming a different environment, an iss pointing at the staging tenant, or a kid referring to a key that was rotated out. The other half are clock skew and a mismatched secret.