Thursday, October 22, 2026

What a JWT Really Reveals: Decoding Tokens and 5 Mistakes That Leak Data (2026)


 <!-- meta: A JWT decoder reveals the payload, not trust. Learn how tokens leak data, why alg=none and kid injection work, and how to verify claims safely. -->

<!-- slug: jwt-decoder-security-mistakes -->

A practical guide to reading JSON Web Tokens without mistaking readability for trust, and to the five mistakes that put real data at risk.

A developer is debugging a login failure. The API returns a token, the frontend rejects it, and the fastest way to see what is inside is to search for "JWT decoder," open the first result, and paste the token into the box. The page instantly renders a header, a payload, and a signature. It looks harmless. It is not. That token is a live credential, and it may carry an email address, a user ID, a tenant name, and a role. The site now has it, along with every proxy, log, and browser extension that saw the request. Nothing was "decrypted," because nothing was ever encrypted. The token was simply read, exactly as the specification intends. What follows is what a JWT reveals and why readability is not trust.

The short answer

A JWT is a signed, base64url-encoded container. Its header and payload are readable by anyone holding the string. The signature proves integrity only if you verify it with the right key and the right algorithm. Decoding tells you what the token claims. Verification tells you whether you should believe it. A common failure is collapsing those two steps into one, or verifying with settings an attacker can influence.

What a JWT is, and what decoding does not prove

A JWT has three parts separated by dots: header, payload, and signature. Each part is base64url encoded, which is base64 with + replaced by -, / replaced by _, and padding removed. Base64url is an encoding, not a cipher. It exists so binary signature bytes and JSON can travel safely through headers, URLs, and cookies. It provides no confidentiality whatsoever.

The signature is computed over base64url(header) + "." + base64url(payload), using the algorithm named in the header. That detail matters: the algorithm is attacker-controlled until you validate it. The payload is JSON. Registered claims such as iss and exp are defined by RFC 7519; others are private to your application. A compact JWT looks like three opaque blobs, but two are plain text in disguise.

Decoding is not verification

This is the sentence to tape to the monitor: decoding is not verification. A JWT decoder is a JSON viewer with extra steps. It can show you "role":"admin" in a token you just invented in a text editor, because the payload has no magical protection. Anyone can craft a payload, base64url-encode it, and put a garbage signature on the end. A decoder will happily display it.

Verification is a different operation. It recomputes the signature over the exact header and payload bytes, compares it in constant time, and checks that the algorithm used is one you expected. Then, and only then, do the claims become input worth considering. Even after a valid signature, you still have to validate exp, nbf, aud, and iss yourself or through a library that does it correctly.

Treat decoded values as untrusted until verification succeeds, including the header. Do not make routing or key-selection decisions from unverified header fields.

A decoded token, line by line

Here is a typical access token split into its readable parts. The signature is omitted here because it is binary noise in this context, but in the real token it is the third segment.

Header, base64url decoded:
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "2026-04-key-01"
}

Payload, base64url decoded:
{
  "iss": "https://auth.example.com",
  "sub": "user_8f21c",
  "aud": "billing-api",
  "exp": 1782350400,
  "iat": 1782346800,
  "nbf": 1782346800,
  "jti": "b7c1e9a4-2f0d-4a51-9a2e-6f1f0f5c9d11",
  "email": "dana@example.com",
  "role": "admin",
  "tenant": "acme-prod"
}

Three things jump out. The email and tenant are fully readable. role sits there, inviting a frontend to trust it. exp is a Unix timestamp in seconds, not milliseconds or an ISO date. Each is a place where implementations go wrong. Verification, by contrast, is short and explicit:

import jwt from "jsonwebtoken";

const claims = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],
  issuer: "https://auth.example.com",
  audience: "billing-api",
  clockTolerance: 60
});

The algorithm list is a policy, not a hint. Issuer and audience are compared exactly, and the tolerance is small and deliberate. If any check fails, verify throws and no claim is trusted.

Mistake 1: trusting alg=none

The JWT specification defines none as a valid algorithm value for unsecured tokens. It exists for cases where integrity is provided by other means, and it is widely disabled in libraries for good reason. The classic attack is simple: take a valid token, change the header to {"alg":"none","typ":"JWT"}, strip the signature, and send it. If the server's verification path accepts whatever algorithm the header names, the token passes. The attacker has just promoted themselves by editing a JSON field.

The defense is to pin the expected algorithm on the verifying side and refuse everything else, including none and any algorithm you did not explicitly enable. Never let the header choose the verification strategy. An explicit allowlist makes the policy visible:

const verifiers = new Map([
  ["RS256", verifyRsa],
  ["ES256", verifyEcdsa]
]);
const verify = verifiers.get(header.alg);
if (!verify) throw new Error("algorithm not allowed");
verify(token, key);

A library that defaults to "use the header" is a library you must configure, not a library you can trust out of the box. The same rule applies to key type: an RSA key must not be usable as an HMAC secret, and an EC key must not be accepted where an RSA key is expected.

Mistake 2: HS256 versus RS256 key confusion

HS256 is HMAC with SHA-256. The signer and verifier share one secret. RS256 is RSA with SHA-256. The signer holds a private key and the verifier holds a public key. They solve different problems, and mixing them up is a well-known vulnerability class.

The confusion attack works like this. A service publishes its RSA public key so clients can verify tokens. The verifier is configured loosely and accepts either RS256 or HS256. An attacker takes that public key and uses it as the HMAC secret to sign a forged token with "alg":"HS256". If the verifier treats the same key material as an HMAC secret, the signature checks out, and the attacker never needed the private key.

The fix is structural. Pin the algorithm, keep verification keys separate from signing keys, and reject any token whose alg differs from the one configured for that issuer. If you are testing HS256 locally, verify it with an HS256 verification tool that takes the shared secret explicitly rather than inferring it from a key set. Key material and algorithm policy must be decided by the verifier, never by the token.

Mistake 3: kid header injection

The kid (key ID) header tells the verifier which key to use. That is convenient when keys rotate. It is also an injection point when implementations build a lookup from it. Two patterns recur. In the file-system variant, code constructs a path like keys/ + kid + .pem and reads it. A kid containing ../../../../etc/passwd or an absolute path can escape the directory. In the SQL variant, code interpolates kid into a query that fetches the key row, and a crafted value alters the query.

Both are ordinary injection bugs wearing a JWT costume. The header is unverified at the moment you need it to select a key, so it is fully attacker-controlled. Defenses are equally ordinary: map kid through a strict allowlist of known key identifiers, never concatenate it into a path or query, reject values outside a safe character set and length, and resolve keys from a fixed set scoped to the issuer you already trust.

Mistake 4: ignoring the claims that matter

A signature proves the token was issued by someone holding the key. It says nothing about whether the token is still valid, who it was for, or whether it is being replayed. The registered claims are small, but each closes a specific hole.

ClaimMeaningWhat the verifier must do
issIssuer identifierCompare exactly against a configured, trusted issuer
subSubject, usually the user IDUse as the identity key after verification, never as a role
audIntended audienceReject tokens minted for a different service
expExpiration time (seconds since epoch)Reject at or after this time, with a small leeway
nbfNot valid beforeReject tokens used too early, with leeway
iatIssued atSanity-check age; useful for max-age policy
jtiUnique token IDUse for replay detection and revocation lists
scopeGranted permissions (common, not registered)Authorize per request, do not cache blindly

The frequent failure is checking exp and stopping there. A token issued by a staging issuer for an internal tool can be unexpired and still be the wrong token for your API. Audience checks stop a token minted for one service from being replayed against another. Issuer checks stop a rogue issuer from being accepted.

Clock skew, leeway, and the exp trap

exp, nbf, and iat are numeric dates in seconds. Verifiers compare them to their own clock, and clocks drift. A token minted on one host can appear to be issued in the future on another. Good libraries expose a leeway or clock tolerance setting, commonly a minute or so. That window is not slack for attackers; it is a deliberate accommodation for distributed systems. Pick a value based on measured drift, not convenience.

The trap is treating exp as a session timeout. It is a hard ceiling on the token's usefulness, not an idle timer. A user who is active all day still loses access when exp passes unless the client refreshes. Conversely, a stolen token remains usable until exp, no matter how quickly the user logs out, unless you add revocation. If you need to inspect a token's remaining life during debugging, a dedicated JWT expiry checker that runs locally avoids shipping the token somewhere else. And remember that exp in milliseconds, a common bug when generating tokens in JavaScript, produces a date tens of thousands of years away and effectively never expires.

Mistake 5: putting PII and roles in the payload

Because the payload is readable, anything in it is public to anyone who obtains the token. Email addresses, phone numbers, national IDs, internal hostnames, and entitlements leak through logs, browser history, support screenshots, error trackers, and proxies. Tokens also travel in places people forget: Authorization headers captured by observability tools, URLs copied into chat, and crash reports.

Roles are subtler. A role claim is a snapshot from issuance. If an administrator is demoted, their token still says admin until it expires; if a user leaves a tenant, the token still carries it. The real risk is an API that authorizes from the claim without checking current state. Keep authorization on the server, keep claims minimal, and treat a role as a hint to reconcile for sensitive actions.

JWTs are not sessions

A session is server-side state with an identifier the client holds. A JWT is a self-contained assertion, and that difference has consequences. Self-contained tokens are large: a few claims plus a signature run to hundreds of bytes, and group memberships can push past the 4 KB cookie limit. Large tokens are sent on every request, inflate headers, and can break cookie handling when they exceed browser or proxy limits.

They also cannot be invalidated by deleting server state, because there is none. Client logout only forgets the token; it remains valid until exp. Immediate logout across devices requires server-side tracking, which means reintroducing session state. Use short-lived access tokens, rotating refresh tokens, and a revocation mechanism. Do not call a JWT a session and then act surprised when logout does not log anyone out.

Use a JWT decoder locally, not on a random website

Pasting a production token into an arbitrary site hands a live credential to an unknown operator. Even if the site is honest, the token may be captured by analytics, request logs, or third-party scripts and persist in a cache you cannot clear. The same applies to browser extensions and "quick" command-line one-liners that ship the token to a remote API. Decoding needs no network access: base64url decoding is local.

Prefer a private JWT decoder that processes the token in the browser without transmitting it, or decode it locally. When you must use a hosted tool, use a synthetic token with the same shape, never the real credential. Redact signatures and identifying claims in screenshots. A JWT decoder online is fine for learning and test tokens; it is not fine for anything that grants production access.

Revocation: you cannot un-issue a JWT

A signed token is valid until it expires, and no amount of client-side deletion changes that. If a laptop is stolen, a token leaks, or an employee is terminated, the token remains usable for the remainder of its lifetime. This is the revocation problem, and there is no free fix.

The realistic options are short expiry, a denylist keyed by jti, and a per-user token version checked on each request. Short expiry limits the blast radius. A denylist gives precise revocation at the cost of a lookup per request. A token version is cheaper: bump it on logout-all or password change, and reject older tokens. Refresh tokens make the combination workable, because the long-lived credential can be rotated while access tokens stay brief and stateless. Choose deliberately; the wrong answer is assuming that "signed" means "revocable."

Frequently asked questions

Is a JWT encrypted?

Usually not. A standard signed JWT (JWS) is encoded and signed, not encrypted, so anyone with the token can read the header and payload. Encryption exists in the JWE format, but it is a different structure and is not what most people mean when they say JWT. If confidentiality matters, do not put secrets in the payload.

Can anyone decode my JWT?

Yes. Base64url decoding requires no key, no password, and no special tool. Anything you can paste into a decoder, an attacker can paste into a decoder too. The signature prevents modification, not reading.

What is the difference between decoding and verifying?

Decoding is a reversible formatting step that produces JSON. Verification recomputes the signature with a trusted key, checks the algorithm is the one you expect, and validates the time and audience claims. Only the second step produces trustworthy data.

Why does my token have alg set to none?

Either the issuer deliberately created an unsecured token, or something stripped the algorithm. In production, none should be rejected outright. A verifier that accepts it will accept any payload an attacker writes.

Should I store a JWT in localStorage or a cookie?

Both have tradeoffs. localStorage is readable by any script on the page, which makes XSS worse. Cookies can be marked HttpOnly, Secure, and SameSite, which reduces script access but introduces CSRF considerations. Neither choice fixes a token that is too long-lived or carries too much data.

How long should a JWT last?

Short enough that a leaked token has limited value. Many systems use minutes for access tokens and days or weeks for refresh tokens with rotation. The right number depends on your risk tolerance and your ability to revoke, not on a universal constant.

Can I revoke a JWT?

Not by itself. You can let it expire, deny it through a jti denylist, or reject it using a per-user token version. Each approach adds state or latency, which is the cost of revocation in a stateless design.

What is clock skew and how much leeway should I allow?

Clock skew is the difference between the issuer's clock and the verifier's. A small leeway, a minute or so, prevents valid tokens from being rejected at the exp and nbf boundaries. Measure your actual drift and keep the window as small as your infrastructure allows.

Is it safe to use an online JWT decoder?

Only for tokens that grant nothing. A production token is a credential; pasting it into a third-party site is equivalent to sharing a password. Use a local or in-browser decoder that does not transmit the token, and redact screenshots.

Why is my JWT so large?

Every claim adds bytes, and the token repeats on every request. Group lists, permission arrays, and profile data are common culprits. If it approaches cookie or header limits, move non-essential data server-side and keep only an identifier.

The bottom line

A JWT is a readable envelope with a tamper-evident seal. The seal is valuable only when you check it against the right key, the right algorithm, and the right claims. Decoding is a convenience for humans; verification is the security boundary. The five mistakes in this article all share one root cause: letting the token, or the person holding it, decide what the verifier should trust. Pin your algorithms, separate your keys, treat kid as hostile input, validate every registered claim, keep personal data out of the payload, and accept that revocation requires real state. Do that, and a JWT decoder becomes what it should have been all along: a debugging tool, not a breach.

No comments:

Post a Comment

Aspect Ratio, DPI and Pixels: The Math Most People Get Wrong (2026)

 <!-- meta: Aspect ratio, DPI and pixel math explained: why 72 DPI can print sharp, how to size images correctly, and the formulas most g...