JSON Web Tokens (JWT) Security: Everything I Check When Testing JWT-Based APIs

JSON Web Tokens (JWT) Security: Everything I Check When Testing JWT-Based APIs

JSON Web Tokens are everywhere in modern API authentication, and that’s exactly why I spend so much time testing them. A JWT looks harmless — just a base64-encoded string — but the way it’s generated, signed, and validated can open the door to full authentication bypass if even one thing is done wrong. In this guide, I’ll break down what a JWT actually is, how it’s structured, and every technique I use to test JWT implementations for weaknesses.

What Is a JWT, Structurally?

A JWT is made of three parts, separated by dots, each base64url-encoded:

header.payload.signature

Header — specifies the token type and signing algorithm:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload — contains claims, which are just key-value pairs of data:

{
  "sub": "1234567890",
  "name": "John Doe",
  "role": "user",
  "exp": 1716239022
}

Signature — a cryptographic signature over the header and payload, generated using the algorithm specified in the header and a secret (for HMAC) or private key (for RSA/ECDSA).

The important thing I always remind myself: the header and payload are only encoded, not encrypted. Anyone can decode and read them. The security of a JWT relies entirely on the signature verification being done correctly.

Why JWTs Are a High-Value Testing Target

Because JWTs are self-contained and often trusted implicitly once decoded, a flaw in validation logic can let an attacker forge a token that grants full access — no database lookup, no session store check, nothing to catch the forgery except the signature verification itself. That’s a single point of failure, and I test it accordingly.

Step 1: Decoding and Understanding the Token

Before attacking anything, I decode the JWT (any base64url decoder works, or jwt.io for a quick manual look) and study:

  • The signing algorithm in the header
  • Every claim in the payload — looking especially for role, isAdmin, permissions, scope, userId, tenantId
  • The expiration (exp), issued-at (iat), and not-before (nbf) claims
  • Whether there’s a kid (Key ID) header claiming which key was used to sign it

Step 2: The Algorithm Confusion Attack (alg: none)

This is the classic JWT attack, and I still find it works on plenty of custom implementations. Some JWT libraries, if misconfigured, will accept a token with the algorithm set to none, meaning no signature verification happens at all.

I test this by taking a legitimate token, modifying the header to:

{
  "alg": "none",
  "typ": "JWT"
}

Then modifying the payload however I like (e.g., changing role: user to role: admin), and submitting the token with an empty signature section:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4ifQ.

If the server accepts this, that’s full authentication bypass, and it’s a critical finding.

Step 3: The RS256 to HS256 Algorithm Confusion Attack

This one is more subtle and, in my experience, more common in real-world APIs. Many APIs use RS256 (asymmetric — a private key signs, a public key verifies) so the server can safely share its public key with other services. The vulnerability happens when a poorly written verification function is told “use whatever key I give you” combined with “trust whatever algorithm the token header says.”

Here’s the attack: since RS256 public keys are, well, public, an attacker can find the public key (sometimes exposed at /.well-known/jwks.json or embedded in a mobile app). They then craft a new token, set the algorithm to HS256 (symmetric), and sign it using the public key as the HMAC secret. If the server’s verification logic does something like:

jwt.verify(token, publicKey) // without specifying expected algorithm

…it may use the public key as an HMAC secret because the token header said HS256, and the signature will validate successfully — because the attacker signed it with exactly that key. This lets the attacker forge any token they want.

I always test this by:

  1. Locating the public key (JWKS endpoint, certificate, or embedded in client code)
  2. Crafting a token with a modified payload
  3. Signing it with HS256 using the public key as the secret
  4. Submitting it and checking if it’s accepted

Step 4: Testing Weak or Guessable Signing Secrets

If the API uses HS256, the signature depends on a shared secret string. I test whether that secret is weak enough to brute-force offline using tools that try common passwords, dictionary words, and default values (secret, changeme, company name, etc.) against the token’s signature.

hashcat -a 0 -m 16500 jwt.txt wordlist.txt

Cracking the secret means I can forge any token I want, with any claims.

Step 5: Testing the kid Header for Injection

The kid (Key ID) header tells the server which key to use for verification, often pointing to a file path, database entry, or key lookup. I test:

  • Path traversal in kid — if the server reads a key from a file path built using the kid value, I try injecting ../../../../dev/null to force verification against an empty or predictable file.
  • SQL injection in kid — if the key is looked up from a database, I test if the kid value is properly sanitized.
  • Arbitrary key injection — if I can supply my own key (via a jku header pointing to a URL I control, for example), I test whether the server actually fetches and trusts that external key source.

Step 6: Testing the jku and x5u Headers

Some JWT implementations support headers like jku (JWK Set URL) or x5u (X.509 URL) that tell the verifier where to fetch the public key from. I test whether:

  • The server actually fetches from a URL I control if I set jku to my own domain hosting a JWKS file with a key I generated myself.
  • If successful, I can sign tokens with my own private key and have the server trust them completely, since it’s fetching my public key to verify against.

Step 7: Testing Claim Tampering Beyond the Signature

Even with a properly validated signature, I still test:

  • Expiration handling — does the server actually check exp, or does it accept expired tokens? I test by using a token with a manipulated future or past expiration.
  • Audience and issuer validation — if the API is part of a larger ecosystem, does it verify the aud (audience) and iss (issuer) claims, or would a token issued for a completely different service also work here?
  • Token replay — is there any mechanism to invalidate a token before its natural expiration (like a token blocklist for logout), or does a “logged out” token remain valid until it expires naturally?
  • Privilege claims trust — I test what happens if I add claims the server doesn’t expect, like adding an isAdmin: true field to a token that otherwise wasn’t designed with that claim, in case some part of the codebase checks for it without validating it was actually meant to be there.

Step 8: Testing Token Storage and Transmission

Beyond the token’s internal structure, I check how it’s handled around the API:

  • Is the token sent over HTTPS only, or could it leak over an unencrypted connection?
  • Is it stored in localStorage (vulnerable to XSS theft) versus an HttpOnly secure cookie?
  • Does the API accept the token from multiple locations (Authorization header, query string, cookie) in a way that increases leak risk, like tokens appearing in server access logs when passed via URL query parameters?

Tools I Use for JWT Testing

  • jwt.io — for quick manual decode/encode during testing
  • jwt_tool — a dedicated Python tool for automated JWT attack testing, including algorithm confusion and known vulnerability checks
  • Burp Suite JWT Editor extension — lets me modify, re-sign, and replay tokens directly within Burp’s workflow
  • hashcat / john the ripper — for offline secret brute-forcing

Common JWT Vulnerabilities I Keep Finding

  1. alg: none accepted by the server.
  2. RS256-to-HS256 algorithm confusion due to improper library usage.
  3. Weak, guessable HMAC secrets.
  4. kid header trusted without sanitization, enabling path traversal or injection.
  5. jku/x5u headers trusted without restricting to known, allow-listed domains.
  6. No server-side mechanism to revoke tokens before natural expiration.
  7. Missing aud/iss validation, allowing cross-service token reuse.

How I Recommend Fixing JWT Issues

  • Always explicitly specify the expected algorithm when verifying a token — never trust the algorithm from the token header itself.
  • Reject alg: none outright at the library configuration level.
  • Use strong, randomly generated secrets for HMAC signing (at least 256 bits of entropy), or better, use asymmetric signing (RS256/ES256) properly.
  • Restrict jku/x5u to a strict allow-list of trusted domains, or avoid supporting them entirely if not needed.
  • Validate exp, nbf, aud, and iss claims on every request, not just signature validity.
  • Implement a short-lived access token plus refresh token pattern, with a server-side revocation list for refresh tokens.
  • Store tokens in HttpOnly, Secure, SameSite cookies where possible instead of localStorage.

Final Thoughts

JWTs put a lot of trust in the verification logic being airtight, and in my testing experience, that logic is where things go wrong more often than the cryptography itself. Most JWT vulnerabilities I find aren’t about breaking strong encryption — they’re about developers trusting fields they shouldn’t, or misconfiguring a library’s default behavior. Test the verification logic as thoroughly as you’d test any other piece of authentication code.

Total
0
Shares

Leave a Reply

Previous Post
Information Disclosure in APIs: How I Find Data Leaks Before Attackers Do

Information Disclosure in APIs: How I Find Data Leaks Before Attackers Do

Next Post
GraphQL Security Testing: How I Find Vulnerabilities in GraphQL APIs

GraphQL Security Testing: How I Find Vulnerabilities in GraphQL APIs

Related Posts