A compact-serialisation JWT is header.payload.signature. The header is JSON such as {"alg":"RS256","typ":"JWT"}, the payload holds the claims, and the signature covers the first two segments joined by the dot. All three use base64url, not standard base64: + becomes -, / becomes _, and trailing = padding is stripped. The missing padding is why a plain base64 -d so often prints "invalid input".
Pasting a token into a hosted decoder page means pasting a live bearer credential into someone else's DOM, browser history and possibly their analytics. The well-known online decoders do the work client-side, but you cannot audit that from the outside, and a shoulder-surfed screen share is enough. For anything issued by a production identity provider, decode locally. GNU coreutils base64 accepts extra padding without complaint, so append two = characters and translate the alphabet:
T='eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjMifQ.sig'
# payload, base64url-safe, with padding fixed
echo "$T" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
# header
echo "$T" | cut -d. -f1 | tr '_-' '/+' | base64 -di | jq .
On macOS use base64 -D. If you want a real tool rather than a pipeline, jwt-cli (jwt decode $T) and step crypto jwt inspect both handle padding and pretty-print claims.
This is the single mistake that turns a JWT bug into a breach. Base64 is an encoding, not a seal — anyone holding a token can rewrite the payload and re-encode it in about four seconds. A decoder tells you what the token says. Only a signature check against the issuer's key tells you whether the issuer said it. In library terms, jwt.decode() in PyJWT without key= and algorithms=, or jsonwebtoken's jwt.decode() instead of jwt.verify(), are debugging helpers. They must never appear on an authorization path.
none and sends an empty third segment. A verifier that trusts the header's declared algorithm accepts it. Fix: pass an explicit allow-list, e.g. algorithms=["RS256"], and never read alg from the token to decide how to check it.| Claim | Check |
|---|---|
exp | Unix seconds, not milliseconds. Allow 30-60s clock skew, no more. |
nbf / iat | Reject tokens not yet valid, and treat a far-future iat as suspicious. |
iss | Exact string match against your configured issuer URL. |
aud | Your API's identifier. Skipping this lets a token minted for service A be replayed at service B. |
kid | Use it to select from a cached JWKS, but never to fetch an attacker-supplied URL. |
For an RS256 token you can check the signature with OpenSSL alone. Write the signing input (header.payload) to one file, the base64url-decoded signature to another, then:
openssl dgst -sha256 -verify pub.pem -signature sig.bin signing_input.txt
# Verified OK
That is useful for a one-off incident check. In an application, use a maintained library and let it fetch and cache the JWKS. Cache by key ID with a TTL of a few hours and a rate-limited refresh on cache miss, so a key rotation does not turn into a stampede against the identity provider.
Reading a JWT in front-end JavaScript to decide whether to show an admin panel is a UI convenience, nothing more. The token is attacker-controlled the moment it reaches the client, so every gate it opens must be re-checked server-side against a verified token. Two practical consequences: keep secrets like internal user IDs, role hierarchies and feature entitlements out of the payload if you would not publish them, and remember that JWT payloads are visible in browser devtools, in proxy logs and in any URL a token accidentally leaks into.