ekofyi
Security Research13 min read

Your JWT Decoder Is Missing These Attacks (Even This Better One)

A new client-side JWT decoder catches subtle token flaws that jwt.io ignores. But securing JWTs is still a server-side problem. Here's the full picture from someone who's exploited these bugs in production.

I saw a new tool this week — published on June 11th — that claims to decode JWTs better than jwt.io, entirely in the browser, and flag vulnerabilities the old stalwart misses. Mathias Kouadio built it, and I respect the hustle. I've been tearing apart JWT implementations for years, both in bug bounties and during pentests, and I've watched the same handful of mistakes destroy auth systems over and over again. So when I heard "finds vulnerabilities jwt.io misses," I didn't just nod. I opened it up, threw a dozen toxic tokens at it, and thought about how many developers still don't understand where JWT validation actually needs to happen.

Here’s the uncomfortable truth: no purely client-side tool can fully secure your JWTs. That’s not a dig at this decoder. It’s the nature of the beast. A browser-based inspector can spot suspicious claim patterns, missing alg fields, absurdly weak keys, and a half-dozen other red flags. That’s genuinely useful. But the attacks that actually break systems — algorithm confusion, key ID injection, "none" algorithm bypass, token substitution across audiences — those succeed or fail on the server. A decoder that lives in your browser cannot tell you if your backend actually verified the signature. It can only show you the token’s guts and make educated guesses.

That said, this tool gets the guesswork right more often than jwt.io does. And that’s worth talking about, partly because jwt.io set the bar so low, and partly because this kind of static analysis can tighten your dev loop in ways you might not expect.

What jwt.io Gets Wrong (And Why It Matters)

jwt.io has been the de facto decoder since JSON Web Tokens became a thing. Paste a token, get a pretty header/payload/signature breakdown, maybe debug a iat timestamp. But the site’s logic is barebones. It decodes base64url parts, tries to verify a signature if you paste a key, and leaves everything else to the user.

I’ve seen tokens on jwt.io with the "alg": "none" header displayed plainly, and the site just renders them — no warning, no red banner, nothing. If you don’t know what you’re looking at, you scroll past it. The same goes for HS256 tokens signed with a guessable secret like secret (which jwt.io will happily verify as valid if you give it that secret). It won’t tell you that the kid header looks suspiciously like a path traversal string. It won’t highlight that the iss and aud claims don’t match what you expected. It’s a visual decoder, not a security auditor.

Kouadio’s tool flips that. It does a first-pass static analysis right in the browser, scanning for the set of issues that observability-minded engineers have learned to spot manually. And it’s fast. Because it’s 100% client-side, the token never leaves your machine — a nice privacy win if you’re pasting production tokens during an incident response.

The Client-Side Detection Engine: What It Actually Catches

After playing with it, here are the vulnerability classes it calls out, and why each one matters if you’re shipping JWTs in a real product.

1. The "alg": "none" Header

The original JWT spec allowed an alg value of none to indicate an unsigned token. In theory, you’d only use this internally when you have external integrity protection, like HMAC at a transport layer. In practice, many libraries accepted "none" tokens without verifying any signature, and attackers had a field day.

If a decoder flags "alg": "none", it’s screaming at you: "Your server had better reject this." The tool I tested doesn’t just note the presence; it explicitly warns that some vulnerable implementations will accept this token as valid regardless of the signature. That’s a level of context jwt.io has never provided.

javascript
// Vulnerable Node.js snippet using jsonwebtoken < 9.0.0 with algorithms: ["none"]
const decoded = jwt.verify(token, null, { algorithms: ["none"] });
// This silently accepts unsigned tokens if the library allows it.

2. Algorithm Confusion (RS256 vs. HS256)

This attack vector is infamous because it’s elegant and devastating. When a server accepts both asymmetric algorithms (RS256, ES256) and symmetric ones (HS256), an attacker can take the application’s public RSA key, which is often easily discoverable, and use it as the HMAC secret for an HS256 token. The server, if misconfigured, will verify the HMAC using the public key and accept the forged token.

The decoder flags tokens where the header claims alg: "HS256" but the surrounding system might expect an asymmetric one. It can’t know for certain what your server expects, but it raises the flag, forcing you to think about whether your backend properly restricts allowed algorithms.

I’ve personally exploited this on multiple bounty programs. You grab the JWKS endpoint, extract the n and e parameters, base64-encode them, and use that string as the HMAC secret to sign an admin token. The server says “checks out” and hands you the keys to the kingdom. A decoder that nudges developers to think about algorithm lock-down is doing holy work.

3. Weak HMAC Secrets

If a token uses HS256, HS384, or HS512, the decoder can attempt to detect whether the secret is guessable or excessively short. This isn’t brute-forcing the actual secret — that’s computationally infeasible in a browser — but it checks patterns: is the secret a 3-character string like "123"? Is it the word "secret"? Does it appear in a known list of weak secrets?

For development and staging environments, this is gold. I’ve lost count of how many times I found production JWTs signed with "devsecret" because someone copied a config file from localhost. A decoder that yells about weak secrets when you paste a token can prevent that from reaching production.

4. Missing or Inconsistent Time Claims

The exp, nbf, and iat claims control token lifetime and validity windows. The decoder checks whether exp is far in the future (tokens set to never expire), whether nbf is in the past (meaning the token is already active), and whether iat is ludicrously old or in the future. These checks are simple but often overlooked.

I once found an API that accepted JWTs with exp set to 9999999999 (year 2286). The token was effectively immortal. The decoder would immediately highlight that the expiration is absurd. That’s a fast feedback loop that would have saved that company from a nasty session hijacking risk.

5. Suspicious Claim Values

Beyond time, the decoder inspects other registered claims: iss, aud, sub, jti. It looks for anomalies — an aud that doesn’t match common patterns, an iss that contains a URL that doesn’t resolve, a jti that appears to be a static string instead of a unique ID. These aren’t vulnerabilities on their own, but they’re warning signs of misconfiguration that can lead to token substitution or cross-service relay attacks.

For example, an aud claim set to ["myapp", "partner-api"] might indicate that the token was issued for multiple audiences, potentially allowing it to be replayed across services if the server doesn’t strictly check audience scope. The decoder won’t tell you you’re vulnerable, but it will invite you to investigate. That’s how you build security intuition.

The Hard Truth: Decoding Is Not Validation

Now, the part that makes me twitchy. Developers love tools like this, and I understand why: seeing your token’s payload decoded with color-coded warnings feels empowering. But decoding a token and validating a token are two fundamentally different operations. The browser can’t validate a signature without the private key or the server’s public key, and it certainly can’t tell you whether your backend’s code is checking those claims against business rules.

I’ve seen teams paste a token into a decoder, see green checkmarks, and assume everything is fine. Then I’d send them a token signed with the none algorithm or an HMAC secret I reconstructed from a public key, and their server would accept it. The decoder had no way of knowing that their backend’s jwt.verify() was missing the algorithms option.

A decoder can tell you what the token says. Only your server’s validation logic can tell you what the token means.

This tool is a static analysis assistant for tokens. It improves on jwt.io by catching patterns that are statistically correlated with vulnerabilities. That’s a real contribution. But if you walk away thinking, “I’ll just paste my tokens here before deploying,” you’ve misunderstood the problem. The decoder is a spot-check. The actual defense lives in your server code.

What Real JWT Security Looks Like (Server-Side)

If you’re integrating JWTs today, here’s the checklist that matters, regardless of what any decoder tells you.

Lock Down the Algorithm

Never, ever let your JWT library auto-detect the algorithm from the token header. Explicitly pass the algorithm you expect, like { algorithms: ['RS256'] }. If you accept multiple, make sure the key material for symmetric algorithms can’t be derived from public asymmetric keys.

javascript
// Safe Node.js pattern (assuming RS256 keys)
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Validate All Standard Claims

Don’t rely on default behavior. Check iss against a known issuer list, aud against your service’s identifier, and exp/nbf with clock skew tolerance. Most libraries give you callbacks or options for this. Use them religiously.

javascript
const decoded = jwt.verify(token, key, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com',
  audience: 'api-service'
});

Rotate Keys, Use Key IDs Properly

Your JWKS endpoint should serve keys with distinct kid values, and your token should include the matching kid in its header. That way, when you rotate keys (which you will do), old tokens can still be verified until they expire, and new tokens use the new key. Without kid, rotation becomes a nightmare of guessing which key to use.

Monitor Token Patterns in Production

If you’re operating at scale, log metadata about incoming tokens: header algorithm, token issuer, expiration horizon, key ID. Build alerts for an uptick in HS256 tokens if you exclusively use RS256, for tokens with an issuer you don’t recognize, or for tokens with no expiration. This is where automation shines — I’ve built tiny HTTP interceptors that parse incoming Authorization headers and ship structured telemetry to a SIEM. A client-side decoder can’t do that.

What This Tool Gets Spectacularly Right

Even with the server-side disclaimers, I want to highlight something this new decoder nails: it shrinks the gap between “I pasted a JWT” and “I understand its risk surface.” Security tools that lower cognitive overhead without dumbing things down are rare.

Consider the workflow of a developer debugging an OIDC flow. They have an id_token from a provider, and they need to check whether the nonce claim is present, whether the at_hash looks correct, and whether the azp matches the client ID. jwt.io makes that read-only, but with no guidance on what missing values imply. This decoder calls out missing recommended claims explicitly. It doesn’t just display; it teaches.

For junior developers who haven’t memorized RFC 7519, that’s massive. They learn to recognize that a decoded admin token with "role": "user" in the payload is suspicious not because the decoder flagged it but because the mental model the tool encourages makes misconfigurations jump out.

The Architecture of a Client-Side JWT Inspector

I spent way too long looking at how this tool works under the hood. It’s a single-page app, all JavaScript, no backend, no telemetry. The token is split on dots, base64url-decoded via the browser’s built-in atob after minor URL-safe character replacement. The decoded JSON is then run through a series of analysis functions that check each known risk vector and return structured warnings.

One clever design choice: it doesn’t attempt any signature verification at all. Zero. That might sound like a flaw, but it’s actually a strength. By refusing to even pretend it can verify, the tool makes the boundary explicit. You know, as a user, that you’re only seeing token content, not a trust decision. That’s honest, and honesty in security tools is underrated.

Because it’s client-side, you can drop it into a private network, run it locally as a static file, or fork it for internal use with custom checks for your claims conventions. I’ve already written a small extension that adds a custom check for my team’s internal env claim (we use "env": "prod" to prevent staging tokens from leaking to production). It took five minutes and no backend changes.

Where It Falls Short (And What I’d Add)

No tool is perfect. Here’s what I want next.

ECDSA and EdDSA support. The current checks focus heavily on RSA/HMAC. Detecting truncation attacks on ECDSA signatures or spotting weak curves would be a natural evolution. It won’t happen client-side without WebCrypto, but even displaying the curve parameter and warning about NIST P-256 vs. Ed25519 preferences would help.

Context-aware scoring. A token that’s fine for a public-facing read endpoint might be terrifying for an admin API. The tool has no concept of context. If you could tag a token with a “context” (e.g., “admin token”, “third-party integration”), the severity of warnings could change. An exp of 24 hours is fine for a user session; it’s catastrophic for a one-time password reset token.

Decoding of nested tokens. JWTs can be signed then encrypted (JWE), resulting in a nested structure that most decoders can’t unravel. This tool doesn’t handle JWE at all. For now, that’s a separate problem, but many real-world OAuth flows produce encrypted ID tokens that need inspection.

Automation-friendliness. I’d love a CLI interface so I can pipe tokens from curl directly into the analysis engine. Something like:

bash
curl -s api.example.com/auth | jq -r .token | jwtdump --checks

I’m already building that for my own workflow, because pasting tokens into a browser is friction I don’t need at 2 AM incident response.

So, Should You Use It?

Yes. With your eyes open. Kouadio’s decoder is a meaningful upgrade over jwt.io for anyone who wants more than a pretty-printed payload. It’s not a replacement for proper server-side validation, but it’s a damn good second pair of eyes when you’re prototyping, debugging, or reviewing a colleague’s PR.

The bigger win, though, is cultural. Every time a tool lowers the bar for understanding JWT security, we chip away at the cargo-culting that leads developers to copy-paste jsonwebtoken snippets from 2016 without understanding what algorithms: ['RS256'] actually means. I’ll take that progress.

Meanwhile, I’ll keep writing custom checks that run in my CI pipeline, rejecting tokens with insecure characteristics long before they hit a decoder. Because the best debugger is the one you never need. But when you do need it, having one that thinks like an attacker is a sharp knife to carry.

Related posts

Written by Eko

If you found this useful, follow @ekofyi on X for more notes like this — or get in touch if you have a problem to solve.