Guides · Utiltown
JWT: Decoding vs Verifying — What to Trust
Every developer has done it: paste a JWT into a decoder, read the
payload, and act on what it says — exp passed,
role missing, iss unexpected. The workflow is
legitimate and useful, but the boundary between reading a token
and trusting one is where real vulnerabilities live. This guide
draws that line precisely: what decoding is, what verification adds,
which attacks each step stops, and when browser-side inspection is the
right tool.
The three parts, and what each can lie about
A JWT is three base64url segments joined by dots:
header.payload.signature. The first two are
not encrypted — decoding is trivial, which is the point:
JWTs are signed, not secret. The third segment is what turns text into a
credential:
- Header declares the algorithm (
alg) and key id (kid). It is attacker-controlled input: the notoriousalg: "none"attack and the RS256→HS256 downgrade both live here. - Payload carries claims —
sub,exp,iss,aud, plus custom claims like roles or scopes. Everything in here is an assertion by the issuer, true only after verification. - Signature covers the header and payload bytes under the issuer's key. This is the only part a client cannot forge, and the only basis for trusting the other two.
What decoding gives you — and what it never can
Decoding is base64url decode + JSON parse. That is all. It answers debugging questions instantly:
- Why did auth fail? Read
exp/nbfagainst the current time. - Which issuer and audience? Compare
iss/audagainst your config. - Which scopes were actually granted? Check the
scopeorrolesclaim. - Is it even shaped like a JWT? A decoder shows parse errors instead of an opaque 401.
What decoding can never do: prove the payload came from
the issuer. Anyone with a text editor can produce
{"alg":"none","role":"admin"} and a matching signature
format. If any part of your client treats decoded claims as
authorization — hiding admin routes because a decoded role
says so, for example — you have built a UI veneer over an open door. The
server must verify every request regardless of what the client believes.
What verification adds
Verification is server-side work (or explicit, library-backed validation of ID tokens in OIDC flows). It binds the payload to a key:
- Check the signature against the issuer's key — for
RS/ES algorithms, the public key from the issuer's JWKS endpoint
(matched by
kid); for HS256, a shared secret that must never have left the server. - Pin the algorithm. Never trust the header's
algblindly. Configure the expected algorithm (algorithms: ['RS256']) so a craftedalg: noneor a key-confusion downgrade is rejected before any crypto runs. - Validate registered claims:
expandnbfagainst the current time,issagainst the expected issuer,audagainst your client id.
| Threat | Stopped by decoding? | Stopped by verification? |
|---|---|---|
| Forged payload (role=admin) | no | yes — signature mismatch |
alg: none unsigned token | no | yes — pinned algorithm rejects it |
| RS256→HS256 downgrade | no | yes — a public key is not a valid HMAC secret |
| Expired-token replay | no | yes — exp check (but see revocation below) |
| Wrong-audience token reuse | no | yes — aud/iss pinning |
| Confidentiality of claims | neither — JWTs are signed, not encrypted; use JWE or keep payloads minimal | |
Debugging with a browser decoder, safely
The everyday loop — 401 from an API, paste the bearer token into a decoder, read the claims — is fine if two hygiene rules hold:
- The tool must be local. A JWT is a bearer credential: whoever holds it can act as you until it expires. Pasting production tokens into a random website hands that credential to an unknown server — tokens end up in third-party logs exactly this way. The Utiltown JWT Decoder parses entirely in your browser — the token never leaves the tab — and it deliberately does not attempt signature validation, because a client-side tool cannot hold the issuer's key.
- Treat the output as diagnosis, not authorization. The decoder tells you why the server said no. Whether the token is genuine is the server's verdict, delivered by its own verification code.
After verification: the revocation gap
Even a perfectly verified token has a known limitation: exp
is checked once at verification, not re-checked at every use. The
standard mitigations, in order of prevalence:
- Short-lived access tokens (5–15 min) with refresh-token rotation — the exposure window equals the lifetime.
- A denylist for high-risk events (logout, password change) checked at the resource server.
- Versioned claims — a
token_versionclaim bumped on credential change; cheap for single-issuer systems.
Practical checklist
- Client: decode for debugging, never for authorization decisions.
- Server: pin the algorithm, verify against JWKS, check
exp/nbf/iss/aud. - Storage: HttpOnly + SameSite cookies or memory-only tokens; localStorage only with short exp and XSS discipline.
- Never paste live tokens into tools that upload them — decode locally, always.
When the payload holds timestamps you need to correlate with server
logs, the Unix Timestamp Converter turns
iat/exp into readable UTC, and the
JSON Formatter pretty-prints the
decoded payload for a line-by-line review.
Frequently Asked Questions
Does decoding a JWT prove it is valid?
No. Decoding is just base64url plus JSON parsing: anyone can read the header and payload of any token, and anyone can forge a token with any claims inside. Only signature verification — checking the signature against the issuer’s key — proves the payload came from the issuer and was not modified. A decoded JWT is a claim; a verified JWT is evidence.
When is it safe to inspect a JWT in a browser tool?
When you are debugging your own token and understand that nothing you see has been verified. Reading claims (exp, sub, scopes) to diagnose why a request failed is legitimate, everyday work. The risk is behavioral: never let client-side code make authorization decisions from decoded claims — an attacker can edit a payload, re-encode, and claim any role. The Utiltown JWT Decoder runs locally and never sends tokens anywhere, which matters because pasting live tokens into random websites is itself a credential leak.
What does signature verification actually check?
Three things, all required: (1) the signature matches the header and payload bytes under the algorithm, using the key the issuer would have used; (2) the alg in the header is one you expect — blocking the alg:none and RS256→HS256 downgrade attacks; (3) the registered claims hold: exp/nbf in the valid window, iss matches the expected issuer, aud matches your client. Skipping the algorithm pin is how the classic JWT library CVEs happened.
Why is exp not enough even after verification?
exp bounds the token’s lifetime, not its revocation. A verified token stolen one minute after issue stays valid until exp unless the server checks a denylist or you use short-lived access tokens with refresh rotation. Treat exp as an upper bound on exposure, not as a logout mechanism.
Should I store JWTs in localStorage or cookies?
Both have a specific attack to design against. localStorage is readable by any script on the page, so one XSS becomes token theft — mitigate with short expirations, or keep the token in memory only. HttpOnly cookies are unreadable to scripts but need CSRF protection and SameSite configuration. What is never acceptable is pasting production tokens into third-party sites that could log them.