JWT decoding for production: why every online debugger is risky
Most online JWT debuggers send your token through their server. Until the token expires, that's a leaked credential. Here's how to read JWTs safely.
A JSON Web Token (JWT) is, by design, readable by anyone who has it. Anyone with the token can decode its payload and see who it was issued to, when it expires, and what claims it carries. The signature only prevents tampering — it doesn't keep contents private.
That's a feature when the JWT is doing its job: a service that receives the token can read the claims directly without an extra database lookup. It becomes a problem the moment the token gets pasted into a debugger that isn't yours.
The leak nobody talks about
Search "JWT debugger" and you'll get a dozen results. Most of them are servers. You paste a token, the server parses it, the server returns formatted output. Convenient — also a leak.
A typical access token from Auth0, Clerk, Supabase Auth, Firebase Auth, or any home-rolled JWT issuer has an expiry between 15 minutes and 24 hours. During that window, anyone with the token can replay it against the issuing service. Pasting a production token into a third-party debugger means everyone with access to that service's logs, infrastructure, or analytics now has a working credential for your user.
The famous jwt.io was historically backed by analytics that saw input. Many smaller "JWT decode" sites are wrappers around a backend that touches your token. The decoded output isn't the leak — the network round-trip is.
How to decode locally
JWT decoding doesn't require a server. It's three steps in browser JavaScript:
const [headerB64, payloadB64, signature] = jwt.split('.');
function decodeSegment(b64) {
// Base64url → standard Base64 → bytes → JSON
const standard = b64.replace(/-/g, '+').replace(/_/g, '/');
const padded = standard + '='.repeat((4 - standard.length % 4) % 4);
return JSON.parse(atob(padded));
}
const header = decodeSegment(headerB64);
const payload = decodeSegment(payloadB64);
console.log({ header, payload, signature });
That's the whole algorithm. No server, no network call, no log entry. Our JWT decoder runs exactly this — open it, open DevTools, paste a token, watch the Network tab stay quiet.
What's safe to put in a JWT
The payload is readable. Plan accordingly.
- Safe: user ID, role/scope claims, issuer, audience, expiry, issued-at, custom claims like
tenant_idororg_idthat the receiving service needs. - Risky: email address (revealed if the token leaks), full name, anything that could correlate the user across systems.
- Never: passwords, password hashes, API keys to other services, private personal data (medical, financial, etc.), session secrets.
If sensitive data must travel via JWT, use JWE (encrypted JWT) — the payload becomes ciphertext that only holders of the key can read. JWE is rarer in practice because most JWT use cases don't need that level of protection, but it exists for the cases that do.
What the claims actually mean
Standard claims defined in RFC 7519:
iss(issuer): who minted the token. Often a URL likehttps://auth.example.com.sub(subject): who the token is about. Usually a user ID.aud(audience): who's allowed to consume it. A service that thinks it's serving a different audience should reject the token.exp(expiry): Unix timestamp in seconds. After this, the token is invalid.nbf(not before): earliest valid time. Usually equalsiat; can be in the future for delayed activation.iat(issued at): when the token was minted.jti(JWT ID): unique identifier — useful for revocation lists.
Custom claims live alongside these. Don't shadow standard names with custom ones.
Common production bugs
"My token expired immediately." Almost always clock skew. The issuer's clock and the verifier's clock disagree by more than the verifier's allowed skew (typically 30 seconds). Sync NTP, increase the leeway window on the verifier, or both.
"My token decodes but the server rejects it." Signature verification failure. The server has a different signing key than the issuer used. Common when rotating keys without rolling the JWKS endpoint, or when a developer copies a token from staging to production.
"My JWT is enormous." Someone put a user's permissions array (or worse, a session's full state) into the payload. Tokens balloon fast. Move the state to a server-side session, reference it from the JWT with a small ID.
"alg: none shows up in the header." This is a notorious vulnerability if a verifier accepts unsigned tokens. Real tokens use HS256 (symmetric, shared secret), RS256 (asymmetric, RSA), or ES256 (asymmetric, ECDSA). If you see alg: none in production traffic, that's a finding.
Verification, not just decoding
Decoding tells you what a JWT claims. Verification tells you whether to trust those claims. For the latter, use a backend library:
- Node:
jose(modern, supports all standard algorithms and JWE) - Python:
PyJWTorpython-jose - Go:
golang-jwt/jwtv5 - Rust:
jsonwebtoken - Java:
jjwtornimbus-jose-jwt
None of these should ever run in a browser with the verification key embedded — that defeats the point. Verification belongs on the server. Decoding (read-only inspection of an already-issued token) is fine in the browser, and that's exactly what our JWT decoder does.
More posts
Why your AI agent costs 10× what you expected
Agents look cheap in the demo and expensive in production. The gap is almost always one of four things — context bloat, retries, tool-call cascades, or the wrong model. Here's the math.
Prompt injection in production: the defenses that work
Most prompt injection mitigations advertised online don't survive contact with a determined adversary. Here are the four that do — used together, not in isolation.
MCP vs function calling: when each one wins
Function calling and MCP solve overlapping problems with different tradeoffs. Here's the decision tree we use — and the costs that bite when you pick wrong.