JSON Web Tokens have become the default authentication mechanism for a huge share of modern APIs and single-page applications, and in the process they’ve introduced a whole category of implementation-specific bugs that don’t exist with traditional session cookies. I test JWT implementations in nearly every API assessment I do now, and the same handful of misconfigurations keep showing up across completely unrelated applications. In this guide, I’ll walk through JWT security testing methodology — the structure of a token, the common vulnerability classes, and exactly how I test each one.
What JWTs Are and Why Their Security Matters
A JSON Web Token is a compact, self-contained way of representing claims (like user identity and permissions) as a signed token that can be verified without needing to hit a central session store. A JWT has three parts, separated by dots: a header (algorithm and token type), a payload (the claims — user ID, roles, expiration, etc.), and a signature (which cryptographically ties the header and payload together, preventing tampering).
The security model hinges entirely on that signature. If an attacker can forge or bypass signature verification, they can craft arbitrary claims — including elevated privileges — and the server has no way to know the token wasn’t legitimately issued. This makes JWT vulnerabilities disproportionately high-impact: a single implementation flaw can lead directly to full authentication bypass or privilege escalation across an entire API.
Lab Setup for Legal Practice
I test JWT vulnerabilities against applications specifically built to demonstrate these flaws:
- PortSwigger’s Web Security Academy JWT labs — the most comprehensive, structured set of JWT vulnerability scenarios available for free.
- A custom Node.js/Express or Flask API using popular JWT libraries, deliberately misconfigured to reproduce each vulnerability class.
- Burp Suite with the JWT Editor extension, which is essential for decoding, modifying, and re-signing tokens during testing.
- jwt_tool, a dedicated Python command-line tool built specifically for JWT security testing.
pip install jwt-tool
What this does: installs jwt_tool, a purpose-built utility for decoding, tampering with, and attacking JWTs, which automates several of the manual techniques covered below.
Understanding JWT Structure Before Testing
A quick decode illustrates what you’re working with. Given a token like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDQyIiwicm9sZSI6InVzZXIifQ.4f_dGh...
Purpose: the header and payload are just Base64URL-encoded JSON — anyone can decode and read them without knowing any secret. Only the signature requires the secret key (for HMAC algorithms) or a private key (for RSA/ECDSA algorithms) to forge validly. This is a critical point to internalize: JWTs are not encrypted by default, they’re only signed. Never assume a JWT’s payload is confidential.
Methodology: Step-by-Step JWT Testing
Step 1: Decode and Inventory Claims
I decode every JWT the application issues and note the algorithm in use (alg header), the claims present (especially role/permission-related ones), and the expiration handling. Burp’s JWT Editor tab does this automatically when it detects a JWT in a request.
Step 2: Test the “none” Algorithm Attack
One of the most classic JWT vulnerabilities involves the alg header itself. Some poorly implemented verification libraries will accept a token with "alg": "none" and skip signature verification entirely.
python3 jwt_tool.py <original_token> -X a
Purpose: jwt_tool‘s -X a flag automates the “none” algorithm attack, rewriting the header to {"alg":"none"}, stripping the signature, and testing whether the server still accepts the token as valid. If it does, you’ve achieved a complete authentication bypass — you can set any claims you want with zero signature at all.
Step 3: Test Algorithm Confusion (RS256 to HS256)
This is a more subtle and historically very impactful attack. Many applications use RS256 (asymmetric — signed with a private key, verified with a public key). If the verification code doesn’t strictly enforce the expected algorithm, an attacker who obtains the server’s public key (often exposed via a /jwks.json endpoint or embedded in client-side code) can craft a token signed with HS256, using the public key itself as the HMAC secret.
python3 jwt_tool.py <original_token> -X k -pk public_key.pem
Purpose: this automates the algorithm confusion attack — it re-signs the token using HS256 with the provided public key as the secret. If the server’s verification logic naively calls a generic “verify” function without pinning the expected algorithm, it will treat the public key as a valid HMAC secret and accept the forged token.
Step 4: Test for Weak or Guessable HMAC Secrets
For applications using HS256, the signing secret is a shared string. If it’s weak, default, or leaked (in source code repositories, for example), it can be brute-forced offline.
hashcat -a 0 -m 16500 jwt_hash.txt rockyou.txt
Purpose: this runs a dictionary attack against the token’s signature using hashcat’s JWT-specific mode (16500), attempting to recover the HMAC secret from a wordlist. A successful crack means you can now sign arbitrary tokens with valid signatures indefinitely.
Step 5: Test JWK Header Injection
Some implementations allow the jwk (JSON Web Key) parameter directly in the token header, letting the sender specify the public key to verify against. If the server trusts an embedded key without checking it against a known allowlist, an attacker can generate their own key pair, sign a forged token, and embed their own public key in the header.
python3 jwt_tool.py <original_token> -X i
Purpose: jwt_tool‘s -X i automates JWK header injection — generating a new key pair, embedding the public component in the token’s jwk header, and signing the payload with the matching private key. If the server extracts and trusts the embedded key rather than using its own known key, the forged token validates successfully.
Step 6: Test kid (Key ID) Parameter Injection
The kid header claims which key was used to sign the token, often used by servers to look up the correct key from a database or file path. If this lookup isn’t sanitized, it can be abused.
python3 jwt_tool.py <original_token> -T
Purpose: this opens jwt_tool‘s interactive tampering mode, letting you manipulate the kid value directly — testing for SQL injection in a kid-based database lookup, path traversal if kid maps to a filesystem path (e.g., kid: ../../../../dev/null to reference a predictable empty/null key), or command injection depending on how the server resolves the key ID.
Step 7: Test Claim Tampering and Privilege Escalation
Beyond signature attacks, I always test what happens when I simply modify claims and see if the server actually enforces them correctly elsewhere:
{"sub":"1042","role":"admin"}
Purpose: even with signature verification working correctly, this tests whether privilege claims are consistently checked server-side on every protected endpoint, or whether some routes trust a cached/session version of the role that doesn’t match what’s actually validated on each request — a logic gap distinct from cryptographic attacks.
Step 8: Test Expiration and Revocation Handling
JWTs are stateless by design, which creates a real operational challenge: how does a server revoke a token before its natural expiration (on logout, password change, or compromise)?
I test this by capturing a valid token, then performing an action that should invalidate it (like logging out or changing the password), and replaying the original token afterward:
GE T /api/profile HTTP/1.1
Authorization: Bearer <token_captured_before_logout>
Purpose: if this still succeeds after logout, it confirms the application has no server-side revocation mechanism (like a token blocklist or short-lived tokens with refresh rotation) — meaning a stolen token remains valid until natural expiration regardless of any account-level security action.
Step 9: Test the x5u and x5c Header Parameters
Beyond jwk and kid, some JWT implementations support x5u (a URL pointing to an X.509 certificate chain) or x5c (an embedded certificate chain) in the header for signature verification. These introduce their own attack surface:
python3 jwt_tool.py <original_token> -X s -ju https://attacker-lab.local/malicious-cert.json
Purpose: this tests whether the server fetches and trusts a certificate from an attacker-controlled URL specified in the x5u header — if it does, an attacker can host their own certificate, sign a forged token against it, and have the server validate the token as legitimate. This is functionally similar to the jwk injection attack but adds an SSRF dimension, since the server is making an outbound request to fetch the certificate.
Step 10: Test Library-Specific Known CVEs
Beyond generic attack classes, I always fingerprint the specific JWT library in use (often visible through error messages, response headers, or client-side bundle analysis) and check for known CVEs against that exact version. Several major JWT libraries across different languages have had significant historical vulnerabilities, including algorithm confusion bugs baked directly into early library defaults before the ecosystem broadly adopted algorithm pinning as a best practice.
retire --js --path ./downloaded-bundle.js
Purpose: retire.js scans JavaScript bundles for known-vulnerable library versions, which is useful when a JWT verification library is bundled client-side (common in SPA architectures that validate tokens locally before making authenticated requests) rather than purely server-side.
Common Mistakes and Troubleshooting Tips
- Assuming HTTPS makes JWT vulnerabilities irrelevant. Transport security protects tokens in transit, but does nothing against algorithm confusion, weak secrets, or logic flaws in verification code.
- Testing only the “none” algorithm attack and stopping there. Most modern libraries block this specific attack by default now; algorithm confusion and
kidinjection are far more commonly exploitable in current applications. - Forgetting to check for exposed JWKS endpoints.
/.well-known/jwks.jsonor similar paths often leak public keys needed for algorithm confusion attacks. - Not testing claim consistency across different endpoints. Some APIs validate the signature correctly on one route but trust an unverified, cached version of claims elsewhere.
- Overlooking refresh token security. Refresh tokens often have longer lifespans and weaker handling than access tokens, making them a valuable and under-tested target.
- Ignoring token storage on the client side. Even a cryptographically sound JWT is compromised if it’s stored in
localStorageand exposed via an unrelated XSS vulnerability.
Security Risks and Defensive Recommendations
For teams implementing JWT-based authentication, here’s what actually closes these gaps:
- Explicitly pin the expected algorithm in verification code — never accept whatever algorithm the token header claims to use.
- Reject the “none” algorithm outright, and use well-maintained, actively patched JWT libraries rather than custom implementations.
- Never trust embedded
jwkor unsanitizedkidheader values — verify against a server-controlled, known set of keys only. - Use strong, high-entropy secrets for HMAC signing, generated cryptographically rather than chosen manually, and rotate them periodically.
- Keep access tokens short-lived and implement a proper refresh token rotation strategy with server-side revocation capability.
- Store tokens in
HttpOnly,Securecookies where possible rather thanlocalStorage, to reduce exposure to XSS-based theft. - Implement a revocation mechanism (blocklist, short expiry with refresh, or a version/nonce claim checked against a server-side record) for critical actions like logout and password changes.
Testing JWT Claims in Multi-Tenant Applications
For multi-tenant SaaS applications, I add a specific test dimension beyond the standard attack classes: whether a valid, correctly-signed token issued for one tenant can be used to access resources belonging to a different tenant. This isn’t a cryptographic flaw in the token itself — the signature is entirely legitimate — but a logic gap in how the application scopes queries based on a tenant_id claim. I test this by capturing a valid token from Tenant A and attempting to access Tenant B’s resources directly, checking whether the backend actually filters every query by the token’s tenant claim or only relies on it for display purposes while the underlying data access layer remains unscoped. This class of bug has caused some of the more serious real-world multi-tenant SaaS breaches, precisely because it slips past standard JWT signature and algorithm testing entirely.
Frequently Asked Questions
Are JWTs encrypted? No, by default JWTs are only signed, not encrypted — the header and payload are simply Base64URL-encoded and readable by anyone who intercepts the token. Encryption requires a separate standard, JWE, which is far less commonly used.
What’s the most common JWT vulnerability found in real-world assessments? In my experience, algorithm confusion and weak/leaked HMAC secrets are the most frequently exploitable issues today, since the “none” algorithm attack has become well-known and is blocked by most modern libraries by default.
Can I test JWT vulnerabilities without jwt_tool? Yes, Burp Suite’s JWT Editor extension covers most of the same attack classes through a GUI, and manual Base64 decoding/re-encoding works for simpler cases, but jwt_tool automates the more complex attacks efficiently.
Why is algorithm confusion (RS256 to HS256) so dangerous? Because it converts an asymmetric trust model (only the server has the private signing key) into a symmetric one where a publicly known key can act as the signing secret, letting anyone with access to the public key forge valid tokens.
How do I know if an application is vulnerable to JWT revocation issues? Test whether a token captured before a logout or password change still works afterward — if it does, there’s no server-side revocation mechanism, which is a common but often overlooked gap since JWTs are stateless by design.
Is storing JWTs in localStorage always a bad idea? It increases exposure to theft via XSS compared to HttpOnly cookies, since JavaScript can read localStorage directly. Cookie-based storage with HttpOnly and Secure flags is generally the more resilient choice, though it introduces its own CSRF considerations that need separate mitigation.
Do refresh tokens need the same security scrutiny as access tokens? Yes, often more — refresh tokens typically have longer lifespans and, if compromised, can be used to continuously mint new access tokens, making their storage and revocation handling especially important to test.
Conclusion
JWT security testing sits at an interesting intersection of cryptography and implementation logic — the token format itself is well-specified and battle-tested, but the way individual applications verify, store, and revoke tokens introduces plenty of room for serious mistakes. Working through algorithm confusion, header injection, weak secrets, and revocation handling systematically will uncover the vast majority of real-world JWT vulnerabilities, and understanding why each attack works (not just running a tool) is what lets you adapt when you hit a slightly different implementation. Practice against the PortSwigger labs until each attack class feels intuitive, and you’ll be well prepared to assess JWT-based authentication in any API you encounter.
For related authentication and API testing methodology, see my guides on OAuth security testing and broader web application reconnaissance techniques.
References
- PortSwigger Web Security Academy, JWT attacks labs
- RFC 7519, JSON Web Token (JWT) specification
- OWASP JSON Web Token Cheat Sheet for Java