Dev Hub Solutions

Product studio

Get in touch
5 min readjwt / security / fundamentals

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_id or org_id that 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 like https://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 equals iat; 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: PyJWT or python-jose
  • Go: golang-jwt/jwt v5
  • Rust: jsonwebtoken
  • Java: jjwt or nimbus-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.