How to Decode a JWT: A Practical Debugging Guide (with the Base64URL Gotcha Nobody Warns You About)
Auth broken at 3am? This debugging-first guide walks through decoding a JWT, the Base64URL gotcha every developer hits, what each claim actually means when you're hunting a bug, and the security difference between decoding and verifying. With Node, Python, and Go examples.
Something's broken in your login flow. The user signs in, the server returns what looks like a valid response, but every follow-up request comes back with a 401. You've got the token string sitting in your terminal. Now what?
Nine times out of ten, the answer lives in the token itself. Decoding the JWT takes about thirty seconds and tells you what the server actually handed out, what claims are in it, when it expires, and whether the server that's rejecting it agrees with what's inside. This guide is the checklist I wish I'd had during my first few years of debugging auth at 3am — structured around the questions you'll actually ask when a token isn't working, not around an academic introduction to JSON Web Tokens.
We'll cover what's inside a JWT, decode one by hand so you know exactly what's happening under the layer that libraries hide, spend some time on the Base64URL gotcha that eats an hour of most developers' lives the first time they hit it, and walk through the claims that matter when debugging. Then we'll cover decode-versus-verify (a security distinction most tutorials gloss over), the attacks you should know about, where to store tokens, and why pasting production tokens into random online decoders is a bad habit. Paste your token into our JWT decoder if you just need the quick answer; read on for the depth.
The 30-second structural refresh
A JWT is three Base64URL-encoded segments joined with dots. That's it. Everything else — the claims, the expiration, the signing algorithm — is data inside those segments. If you've only seen JWTs as opaque eyJ... strings, this part makes the mystery go away.
Here's a real token structured so you can see it:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyXzhmM2syaiIsImlhdCI6MTcxMTIwMDAwMCwiZXhwIjoxNzExMjAzNjAwLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJyb2xlcyI6WyJ1c2VyIl19
.
3K8p-X_dW7xZg2FfT5rQnX_L0kcP0oj_h_YqH8yW8mE
Three parts, separated by dots:
- Header — tells a verifier which algorithm to use (
HS256,RS256,ES256, and others). It's JSON, Base64URL-encoded. - Payload — the claims. Who the token's for, when it expires, roles, custom fields. Also JSON, also Base64URL-encoded.
- Signature — a cryptographic hash over the first two parts, signed with either a shared secret (HMAC) or a private key (RSA/ECDSA). This is what keeps the token from being forged.
The format is specified by RFC 7519. A quick way to sanity-check a string is actually a JWT: it has exactly two dots, both halves before the second dot start with eyJ (more on that below), and all three segments use only A–Z, a–z, 0–9, -, and _.
The payload is encoded, not encrypted. Anyone holding the token can read what's inside. That's by design. The signature is what stops tampering, not some kind of secrecy on the payload. If you need an encrypted token, that's JWE (RFC 7516), which is a different format entirely and relatively rare in practice.
Decoding a JWT by hand
Pick the payload — the middle segment — and paste it into any Base64 decoder. Except this doesn't quite work, which is the whole reason the next section exists. But let's do the happy path first.
Take the example above. Split on the dots, grab the middle segment:
eyJzdWIiOiJ1c2VyXzhmM2syaiIsImlhdCI6MTcxMTIwMDAwMCwiZXhwIjoxNzExMjAzNjAwLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJyb2xlcyI6WyJ1c2VyIl19
Feed it to a Base64 decoder and you get:
{
"sub": "user_8f3k2j",
"iat": 1711200000,
"exp": 1711203600,
"iss": "https://auth.example.com",
"aud": "api.example.com",
"roles": ["user"]
}
That's the whole secret. Every JWT you've seen in a header or a cookie, on every application you've ever used, decodes like this. Six JSON fields, readable in half a second. A quick way to feel this viscerally is to copy a token from your own app right now, paste it into our JWT decoder, and look at what your auth server has been handing out all along.
The decode process also explains one tiny mystery: every JWT starts with eyJ. That's because every JWT header starts with the JSON {", and {" in Base64URL is always eyJ. No magic, just predictable encoding. So if you ever need a sanity check that some string is a JWT, eyJ at position zero is a strong hint.
The Base64URL gotcha that eats an hour of your life
This is where most developers who try to decode a JWT manually get stuck. The payload you copy out of a header looks like Base64, decodes like Base64 for simple tokens, and then silently fails on real production tokens. The problem: JWTs don't use standard Base64. They use Base64URL, which is almost identical except for the three characters that matter most.
Standard Base64 uses these 64 characters: A–Z, a–z, 0–9, +, /. Padding is the = character. Those three characters — +, /, and = — have meaning in URLs. + is interpreted as a space. / is a path separator. = is a query separator. Stuff a standard Base64 string into a URL and it breaks.
Base64URL fixes this with three substitutions, specified in RFC 4648 Section 5:
| Standard Base64 | Base64URL |
|---|---|
+ | - |
/ | _ |
= (padding) | omitted entirely |
That's the whole difference. But it's enough to break every standard-Base64 decoder on the planet when fed a JWT payload containing - or _.
Here's the failure in action. Take this fragment from a real payload:
eyJzdWIiOiJ1c2VyX2Q4NC1iN2Y...
Notice the - near the end. Pipe that into a classic Base64 decoder like base64 --decode on Linux:
$ echo 'eyJzdWIiOiJ1c2VyX2Q4NC1iN2Y' | base64 --decode
base64: invalid input
Or worse — on some systems you get partial garbage instead of a clear error, and you waste twenty minutes debugging the wrong thing. The fix is mechanical and always the same: replace - with +, replace _ with /, then pad the string to a multiple of four with = characters.
In Python the urlsafe variant handles this for you:
import base64, json
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMi..."
payload = token.split(".")[1]
# Pad to multiple of 4
padded = payload + "=" * (-len(payload) % 4)
decoded = base64.urlsafe_b64decode(padded)
print(json.loads(decoded))
In Node you have Buffer.from(str, "base64url") since Node 16:
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMi...";
const payload = token.split(".")[1];
const json = Buffer.from(payload, "base64url").toString();
console.log(JSON.parse(json));
In the browser, atob only handles standard Base64, so you do the substitution explicitly:
function decodeJwtPayload(token) {
const payload = token.split(".")[1];
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
return JSON.parse(atob(padded));
}
That's the whole gotcha. Once you know it, you'll never be stuck again. Our Base64 encoder and decoder has a URL-safe mode that applies the substitution automatically if you want to decode segments by hand without the padding math.
Debugging a broken token: what to check, in order
When auth breaks, decode the token and run through this checklist. The order matters because each check is cheaper than the next and eliminates the most common failures first.
1. Is exp in the past?
Most auth failures come down to an expired token. exp is a Unix timestamp — seconds since 1970-01-01 UTC. Quick comparison: exp < current time means expired.
If you're reading the payload as JSON, exp will look like 1711203600. That's a number most humans can't read at a glance. Our Unix timestamp converter handles the translation instantly, or in the terminal:
date -d @1711203600 # Linux
date -r 1711203600 # macOS
Also check exp against the server's clock, not your laptop's. Clock skew between services is a surprisingly common cause of "token is valid on one API but expired on another." If your issuer and verifier are in different data centers, a minute or two of drift is normal, and most libraries allow a small leeway (leeway in jsonwebtoken, leeway in PyJWT) which you should leave at about 30 seconds in production.
2. Does iat make sense?
iat (issued at) is when the token was created. If it's in the future, your issuer's clock is ahead of your verifier's — which usually means verification will fail with a "token not yet valid" error even if nbf isn't set. If iat is years in the past, something's weird: you're either dealing with a pre-dated token, replay, or a bug in whatever minted it.
3. Does nbf block the token?
nbf (not before) is rarely used. When it is, it means "reject this token if the current time is earlier than this." If a token has nbf set to a future time, even a perfectly signed token with a valid exp will fail. Look for it when debugging "invalid token" errors that don't match what you'd expect.
4. Does iss match?
iss (issuer) is the entity that created the token. Production auth verifiers should check that iss matches a known trusted value. If your API expects tokens from https://auth.example.com but the token's iss is https://dev-auth.example.com, the API will reject it. This is an extremely common error when promoting code between environments — staging tokens accidentally hitting production APIs, or vice versa.
5. Does aud match?
aud (audience) is who the token is for. If you have multiple APIs behind one auth server, each API usually validates that aud contains its own identifier. A token minted for api.example.com should not work on admin.example.com. When decoding a token for a debugging session, confirm aud actually names the API you're calling. A valid-looking token that the wrong API keeps rejecting almost always comes down to aud.
6. Are the required scopes or roles there?
Auth servers pack authorization data into JWT claims. Depending on your stack this might be scope (OAuth 2.0 convention), roles, permissions, or a custom claim. If the token looks fine and the user's still getting 403s, decode and look at what's actually in that claim. I've lost half-days to this exactly because a valid login handshake was returning a token that simply didn't carry the required scope — the auth server silently dropped it on one of its rules.
7. Does sub point to the right user?
sub (subject) is the user identifier. If your application is handing out one user's data to another, sub mismatch is where you start. This also catches the subtle cases where an impersonation flow works, but not the way you expected: the token says sub: admin_account even though the user swears they logged in as themselves.
Decoding JWTs in code
When you move from one-off debugging to code that needs to read JWTs at runtime — say, reading a claim on the frontend for display purposes, or logging the user ID on every request on the backend — use a library. They handle Base64URL correctly, they handle edge cases in the JSON parse, and their function names usually remind you of what they don't do. A method called decode that doesn't verify is honest; rolling your own with atob and a regex usually works until the day it doesn't.
Node.js
Two libraries cover 99% of use cases:
jwt-decodefrom Auth0 — decode only, no verification. Tiny. Safe for frontend use where you just want to display user data.jsonwebtoken— the server-side package that both decodes and verifies.
// Frontend — read user info from a token for display
import { jwtDecode } from "jwt-decode";
const token = localStorage.getItem("accessToken"); // consider moving off localStorage — more below
const payload = jwtDecode(token);
console.log(payload.sub, payload.email);
// Server — verify a token you received
import jwt from "jsonwebtoken";
try {
const payload = jwt.verify(incomingToken, process.env.JWT_SECRET, {
algorithms: ["HS256"], // hardcode this; see the alg:none section
issuer: "https://auth.example.com",
audience: "api.example.com",
});
// payload is trustable
} catch (err) {
// invalid signature, expired, wrong issuer, wrong audience, etc.
}
jsonwebtoken is mature but has had its share of CVEs. An alternative worth knowing is jose, which is actively maintained by a library author who takes JWT spec quirks seriously.
Python
PyJWT is the standard:
import jwt
# Decode without verifying (debugging only)
payload = jwt.decode(token, options={"verify_signature": False})
# Verify in production
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
issuer="https://auth.example.com",
audience="api.example.com",
)
Setting verify_signature=False is honest — the option name tells you what you're doing. If you find yourself needing that in production code, stop and ask why.
Go
github.com/golang-jwt/jwt/v5 is the maintained fork of the original:
// Parse without verifying
parser := jwt.NewParser()
token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
claims := token.Claims.(jwt.MapClaims)
// Parse with verification
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(secret), nil
})
The method name ParseUnverified makes the danger obvious. The callback pattern in jwt.Parse gives you a chance to reject unexpected signing methods, which is exactly how you defend against the algorithm-confusion attacks we'll cover shortly.
If you want to experiment with custom claims and signing algorithms without installing anything, our JWT generator lets you build test tokens in the browser and the JWT decoder decodes them back.
Decode versus verify: the security line that matters
This is where most JWT tutorials wave their hands. The distinction is the whole game.
Decoding a JWT splits it on the dots and reverses the Base64URL on the first two parts. No key required. It tells you what the token says. It tells you nothing about whether to trust what the token says.
Verifying a JWT checks that the signature in the third part was produced by a party holding the expected key, over the exact bytes in the first two parts. With HS256, the "key" is a shared secret; both the signer and the verifier have the same bytes. With RS256 or ES256, it's asymmetric — a private key signs, and a public key verifies. Either way, verification is what turns "the token says you're an admin" into "I can trust that you're an admin."
The rule I use: decode freely for debugging, logging, and displaying user-facing information on the client. Never grant access based on a decoded-but-unverified claim. If code is about to decide "does this user have permission to delete this record," the token has to be verified first, by the server, with a hardcoded algorithm and a known key. Anything less is trusting the user to send you a valid-looking token, which is exactly what attackers do.
A related rule: don't verify on the client. The secret for HMAC algorithms is supposed to stay on the server. If you ship it to the browser to "verify" the token client-side, the secret is now public, which means anyone can sign their own tokens claiming to be anyone. For HMAC-signed tokens, the cryptographic work belongs on the server — our HMAC generator is handy when you're testing signatures in isolation, but the secret itself stays out of client bundles.
For asymmetric algorithms this is less dangerous: a public key is meant to be public, so client-side verification of an RS256 token doesn't leak anything. Still, you usually don't want to ship that logic to the browser. Keep verification on the server; let the client work with the decoded payload purely for presentation.
The attacks you need to know
Most JWT vulnerabilities are not flaws in the JWT spec. They're specific misconfigurations in libraries or application code. Knowing them prevents you from shipping a system that's broken in the exact ways attackers test for first.
alg: none bypass
The JWT spec includes an algorithm called none, which means "this token is unsecured; don't verify it." It exists for testing and for tokens whose integrity is guaranteed through some other mechanism (encrypted transport, internal systems). It was never meant for production.
The attack: set alg: "none" in the header, drop the signature, and send the token. If the verifier blindly honors the alg field, it skips verification and accepts whatever claims are inside. You have just forged a token.
The fix: hardcode the expected algorithm on the verifier side. Every mature library supports this. In jsonwebtoken the algorithms: ["HS256"] option is exactly this protection — the library refuses any other algorithm including none. Never read the algorithm from the token's own header to decide how to verify.
RS256 → HS256 confusion
An older but still-exploited attack. The server expects RS256 (asymmetric signing) and has the public key loaded. An attacker takes that public key — which is public, so they can get it — and uses it as an HMAC secret to sign a forged token with alg: HS256. If the verifier uses a "key" parameter that doesn't distinguish between "RSA public key" and "HMAC secret," and if it trusts the token's alg field, the verification succeeds. Attacker has forged an admin token.
The fix again: hardcode the expected algorithm. Never let the token's header dictate the verification method. Libraries written after 2015 handle this correctly by default; older versions and homegrown implementations sometimes don't. Our RSA key generator is useful for spinning up test keypairs when you want to reproduce the setup locally.
kid (key ID) injection
Some JWTs include a kid (key ID) header to help the verifier pick which key to use when multiple keys are in rotation. Buggy implementations have used the kid value directly in a file path (/keys/${kid}.pem), enabling path traversal; or in a SQL query, enabling SQL injection; or as a lookup key into a hash map that defaults to a null key when missing.
The fix: treat kid as untrusted input. Validate it against an allowlist, never interpolate it into paths or queries.
If you want a more detailed tour of JWT attack surface, PortSwigger's web security academy has a solid free course with interactive labs. The OWASP JWT cheat sheet covers defensive coding.
Storing tokens: localStorage vs httpOnly cookies
Every other JWT blog has this debate. Here's the short version of my take, with the reasoning.
Storing JWTs in localStorage is common advice, mostly because tutorials make it easy. It's also the default attack surface. Any JavaScript running on your page — including malicious scripts injected through XSS — has unrestricted read access to everything in localStorage. Exfiltrating a token is one line of code. This risk is real and it's the single most common token theft pattern in the wild.
httpOnly cookies — cookies the browser sets and sends but JavaScript cannot read — block that attack entirely. An XSS that can run arbitrary JavaScript on your page still cannot extract the token. It can make requests as the user (the browser attaches the cookie automatically), but it can't take the token elsewhere for later use.
The catch: httpOnly cookies reintroduce CSRF, because they're sent automatically with any cross-origin request the browser decides to make. The defense is SameSite=Lax or SameSite=Strict on the cookie, which prevents most cross-origin sends. Combined with a CSRF token on state-changing endpoints, this covers the gap.
My rule: for anything resembling a real app, use httpOnly; Secure; SameSite=Lax cookies for the access token, pair with short expiration (15 minutes is reasonable) and refresh tokens in a separate httpOnly cookie. localStorage is fine for developer tools and experiments where the token grants no real power.
Privacy: why online decoders are risky
This is where I'll say something uncomfortable, including about how people use our own tool.
A JWT from your real production environment contains real user data. Subject identifiers, email addresses, tenant IDs, role claims, sometimes free-form custom claims with business-specific information. Every time you paste that token into a third-party decoder you're sending that data to a server you don't control.
Reputable decoders — including our JWT decoder — do the work client-side in your browser. The token is decoded in JavaScript that runs on the page you loaded; it's never sent to our backend. We designed it that way on purpose. But you have no way to prove that from the outside. Browser extensions can read page state. Compromised CDN libraries can exfiltrate input. A decoder that's honest today might get acquired tomorrow and turn into a log-everything pipeline next quarter.
The safer habits I'd recommend:
- Use a local tool where you can — running
jqin your terminal, or a desktop JWT app, or our decoder with network devtools open to confirm it's not making outbound requests. - If you must use a web decoder, pick one from a domain you already trust and check that it's client-side only.
- Never paste tokens that haven't expired into any tool you haven't vetted. Expired tokens can be decoded without risk — the data inside might still be sensitive, but at least the token itself can't be used to access anything.
- For production incident debugging, use your own infrastructure. Most observability platforms (Datadog, New Relic, Sentry) will decode JWTs in traces if you tell them to; that way the token data never leaves systems you've already trusted.
Frequently asked questions
Can I decode a JWT without the signing secret?
Yes. Decoding only reverses the Base64URL encoding on the header and payload, which requires no key at all. The secret is only needed for signature verification. This is by design: JWT claims are not encrypted, only signed. If you need the content to be secret, look into JWE instead.
Why does my JWT start with eyJ?
Because every JWT header is a JSON object that begins with {", and {" in Base64URL encoding is always eyJh. It's a predictable artifact of the format. It's not a vulnerability and not a secret.
Why does my Base64 decoder fail on a JWT?
JWTs use Base64URL, not standard Base64. Base64URL replaces + with -, / with _, and omits = padding. A standard Base64 decoder chokes on - and _, and may fail silently on missing padding. Use a library that supports URL-safe Base64, or manually swap the characters and add padding before decoding.
Is it safe to decode a JWT in the browser console?
For your own tokens during debugging, yes. atob runs locally; nothing leaves the browser. The risk isn't in the decoding itself — it's in pasting production tokens into a third-party web tool that could log them. DevTools decoding is fine; random websites are a gamble.
What's the difference between a JWT and a session token?
A session token is usually an opaque random string. The server stores session data somewhere (Redis, a database) and looks it up on each request. A JWT is self-contained: the data itself travels with the token, signed so the server can trust it without a lookup. JWTs work well for stateless APIs and microservices; sessions are often simpler for traditional web apps where you already have a database.
How do I check if a JWT is expired?
Decode it and compare exp to the current Unix timestamp. If exp < now, the token has expired. In production code you normally don't do this check manually — the verification library rejects expired tokens automatically. Manual checking is for debugging.
Can an attacker modify the JWT payload?
They can change the Base64URL bytes, yes. But if the verifier checks the signature correctly, the modified token won't verify. The attack surface is entirely around getting a verifier to skip signature checking or accept a different algorithm than expected — which is the attack family we covered above.
What's alg: none and why is it dangerous?
It's a JWT spec feature meaning "this token has no signature, don't verify." Libraries that honor the token's own alg field can be tricked into accepting forged tokens by setting alg to none and omitting the signature. Hardcode the expected algorithm on the verifier side; never let the header decide.
Where should I store a JWT in a web app?
For access tokens in real applications, httpOnly; Secure; SameSite=Lax cookies. localStorage is convenient but the XSS risk is serious. The cookie route requires a CSRF mitigation on state-changing endpoints, which SameSite plus a CSRF token on forms/POSTs handles.
How long should a JWT live?
Short. Fifteen minutes is a common baseline for access tokens, with refresh tokens (longer-lived, stored more carefully, usually in a separate httpOnly cookie) used to mint new access tokens as needed. The shorter the access token's life, the less damage a stolen one can do.
Can I revoke a JWT before it expires?
Not without adding state somewhere. JWTs are stateless by design, which means once you've issued one, it's valid until exp regardless of what happens. Common approaches: maintain a short-lived token with a refresh flow and revoke the refresh token server-side; keep a token blocklist in Redis; or rotate signing keys and invalidate all tokens signed with the old key. Each adds complexity; pick the lightest that meets your threat model.
What's the difference between decoding and verifying?
Decoding tells you what the token says. Verifying tells you whether to believe it. Decoding needs no key and can be done by anyone. Verifying needs the signing key (shared secret or public key) and is what makes the token trustable. Never grant access based on a decoded-but-unverified token.
One more thing
If you want the deeper authentication context — how JWTs fit into a full login flow, refresh token patterns, SSO, the relationship with OAuth 2.0 — our longer piece on JWT tokens and authentication covers that ground. This post stayed deliberately narrow: decoding, debugging, and the security line between decode and verify, because those are what you need when a token's in your hand and something isn't working.
For the hands-on side: the JWT decoder runs entirely in your browser, the JWT generator lets you mint test tokens with custom claims, and the Unix timestamp converter handles the iat/exp/nbf conversions. None of them send your tokens anywhere.
Good luck with the auth bug.