TL;DR: How do you test JWT security with jwt-cli and Caido?
You test JWT security by using jwt-cli to decode, forge, and re-sign tokens, and Caido to replay and fuzz requests against the target. The four flaws worth testing on every engagement are algorithm confusion (alg: none and RS256-to-HS256 swaps), weak HMAC secrets cracked offline with hashcat, expired or missing temporal claims, and outright missing signature validation.
JSON Web Tokens have quietly become the default session mechanism for APIs, single sign-on platforms, and microservice meshes. That ubiquity is exactly the problem. Traditional session security assumed the server held the state—revoke a session and it’s gone. JWTs invert that model: the token is the state, signed but not secret, and every verification decision hinges on whether the server implements the checks the specification suggests rather than mandates. RFC 8725 (“Best Current Practices for Cryptographic Algorithms in JOSE”), published by the IETF in February 2020, exists largely because implementers kept getting this wrong. The OWASP API Security Top 10 lists Broken Authentication at #2 for a reason—JWT misconfiguration is one of the most common ways to get there.
This guide walks through a repeatable methodology: decode and forge with jwt-cli, replay and fuzz with Caido, and confirm which of the four classic flaws your target actually suffers from. Only test systems you own or have written authorization to assess.
JWT structure refresher: header, payload, signature
A JWT is three Base64url-encoded segments joined by dots: header.payload.signature. The header declares the algorithm (alg) and often a key identifier (kid). The payload carries claims—sub, iss, aud, exp, nbf, iat, and whatever custom data the application stuffed in (frequently role or admin). The signature is the cryptographic glue.
The critical distinction is the signature type:
- HS256 — HMAC-SHA256, symmetric. The same secret signs and verifies. If that secret is weak, the attacker forges anything.
- RS256 — RSA, asymmetric. The private key signs; the public key verifies. The server should never possess signing capability it doesn’t need.
Here’s the architectural flaw everything in this article exploits: the header controls verification, and the header is attacker-controlled. It arrives unencrypted, unsigned until after it’s read, and many libraries historically used it to decide how to verify rather than verifying against a pinned algorithm. You’re not attacking the math—you’re attacking the configuration decisions wrapped around it.
Setting up your test lab: jwt-cli, Caido, and a vulnerable target
Install jwt-cli, the Rust-based tool by Mike Engelman (ludflu/jwt-cli on GitHub, available as jwt-cli on crates.io and via Homebrew):
# Homebrew
brew install jwt-cli
# Cargo (Rust toolchain)
cargo install jwt-cli
Caido is a Rust-based intercepting proxy with replay, automations, and HTTPQL filtering. Download the desktop app from caido.io, point your browser or test client at the proxy (default http://127.0.0.1:8080), and install the CA certificate for HTTPS interception.
For a vulnerable target, use a deliberately broken JWT application—JWT Lab–style challenges or an OWASP-calibrated vulnerable app—hosted locally in Docker. Never point these techniques at production systems. Verify your lab is isolated before firing off forged tokens.
Decoding and inspecting tokens with jwt-cli
Capture a token from your authenticated session (intercept it in Caido, or pull it from your browser’s Authorization header), then decode it without verifying:
jwt decode eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzM1Njg5NjAwfQ.XXXX
Output shows the header and payload in full: the alg, any kid, the exp as a Unix timestamp, and the iss. This is reconnaissance—read everything before you modify anything. jwt encode is your forge:
jwt encode --secret 'known-secret' --claim sub=admin --claim role=superadmin
Flaw 1: Algorithm confusion (alg: none and HS256/RS256 swap)
alg: none is the oldest trick in the JWT book—strip the signature, declare no algorithm, and see if the library accepts an unsigned token:
jwt encode --alg none --claim sub=12345 --claim role=admin --claim exp=1893456000
Send the resulting two-segment token through Caido’s Replay against a protected endpoint. A 200 with elevated access means the server trusts the header.
The subtler variant is the HS256/RS256 swap. If the server issues RS256 tokens but verifies against whatever alg the token declares, you can sign a token with the public key—as an HMAC secret:
jwt encode --alg HS256 --secret @public.pem --claim sub=12345 --claim role=admin --claim exp=1893456000
Some servers fetch the public key from a jwks_uri or a /jwks.json endpoint; fetch it yourself (curl https://target/.well-known/jwks.json), convert if necessary, and use it as the HMAC key. Replay in Caido. If it verifies, you’ve fully bypassed the signature—this is the exact class of flaw behind CVE-2015-9235 in the jsonwebtoken Node library and CVE-2016-10555 in php-jwt-adjacent implementations.
Flaw 2: Weak HMAC secrets and brute-forcing with hashcat
If the server uses HS256 with a guessable secret, the signature is just an HMAC you can crack offline. Export the token (hashcat accepts the full JWT directly) and run:
hashcat -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
Mode 16500 is JWT (HMAC-SHA256); mode 16511 covers JWT with a null-salted variant. Tokens from HS384/HS512 use the same mode family. In a lab with secret as the signing key, hashcat on even modest hardware cracks it in seconds. Once cracked, forge unlimited valid tokens:
jwt encode --secret 'secret' --claim sub=12345 --claim role=admin --claim exp=1893456000
OWASP’s guidance and CISA advisories on authentication hardening converge on the same fix: secrets must be at minimum 256 bits of entropy—randomly generated, not human-chosen words.
Flaw 3: Expired claims and missing exp/nbf validation
Many servers decode tokens but never check the temporal claims. Test it directly:
- Capture a valid token, note the
exp. - Wait for it to expire (or find an already-expired token in logs/proxy history).
- Replay the expired token in Caido. If the endpoint returns 200,
expis decorative. - Extend the window:
jwt encode --secret 'cracked-or-known' --claim exp=1893456000 --claim nbf=0 ...
Also test iss and aud: swap the issuer string to evil, or remove aud entirely. A server that verifies the signature but skips claim validation will accept a token minted for a different audience—which in multi-tenant environments means a token from one tenant’s IdP works against another’s API. This is precisely the failure mode RFC 8725 section 3.7 addresses.
Flaw 4: Missing signature verification and claim tampering
The bluntest test: modify the payload without re-signing. Change "role":"user" to "role":"admin", keep the original signature, replay in Caido. Then try corrupting the signature entirely—swap a character at the end of the third segment. If both requests succeed, the server is decoding, not verifying. This happens more often than you’d think in internal microservice traffic where someone “optimized away” verification for latency.
Use Caido’s Fuzz (intruder-style) functionality to systematicize this: set the Authorization header as a variable, load a list of tampered token variants—flipped signature bytes, stripped signature, modified claims, base64url padding tricks—and observe status code deltas across the board. A cluster of 200s where you expect 401s is your finding.
Hardened configuration: correct library usage and verification flags
Every one of these flaws is a configuration error, not a cryptographic failure. The fix pattern is identical across languages: pin the algorithm, enforce every claim, reject on any mismatch.
Node.js (jsonwebtoken):
jwt.verify(token, publicKey, {
algorithms: ['RS256'], // pinned — never omit
issuer: 'https://auth.example.com',
audience: 'api://orders',
clockTolerance: 30
});
Python (PyJWT):
jwt.decode(
token, public_key,
algorithms=["RS256"],
audience="api://orders",
issuer="https://auth.example.com",
options={"require": ["exp", "iss", "aud", "sub"]}
)
Java (jjwt):
Jwts.parser()
.verifyWith(publicKey)
.requireIssuer("https://auth.example.com")
.requireAudience("api://orders")
.clockSkewSeconds(30)
.build()
.parseSignedClaims(token);
Additional controls worth listing in every remediation report:
- Strong secrets: 256-bit randomly generated HMAC keys, rotated, stored in a secrets manager.
- Short TTLs: access tokens valid for minutes (5–15), paired with refresh tokens and server-side revocation.
- Kid validation: sanitize
kidinputs—path traversal and SQL injection viakidare documented attack vectors. - Fail closed: any unknown algorithm, missing claim, or verification error rejects the token—never falls through to a default.
Building a repeatable JWT testing checklist in Caido
Caido’s advantage over ad-hoc testing is persistence. Build your JWT audit workflow once:
- Replay templates: save each attack variant—
alg:none, HS256-swap, expired replay, stripped signature—as named Replay entries. - Automations: configure Caido automations to flag any request carrying an
Authorization: Bearer eyJ...header, so every token on the proxy gets captured for analysis automatically. - HTTPQL filters: filter proxy history with
req.header.regex:"eyJ[a-zA-Z0-9_-]+" OR req.header.contains:"Bearer"to isolate JWT-bearing traffic across an entire session.
Next engagement, you’re executing a checklist, not rediscovering methodology.
Blue-team detection and prevention takeaways
Testing findings only matter if defenders can see the attempts. Instrument for:
- Signature verification failures — log every one; a spike from a single source is reconnaissance.
- Algorithm mismatches — any incoming
algdiffering from the pinned value is an attack, full stop. Alert immediately. - Expired/replayed tokens — track
jticlaim reuse and post-expusage; both indicate either attacker activity or broken clients worth knowing about.
When reporting findings responsibly—whether internally or through a bug bounty program—include the forged token, the exact request that succeeded, and the hardened library configuration as remediation. Scope matters: bug bounty platforms publish explicit rules of engagement, and testing outside them converts a finding into an incident. Test what you own, document what you break, hand over the fix.
Frequently Asked Questions
Is it legal to test JWT implementations?
Only on systems you own or have written authorization to test—your own lab, in-scope bug bounty targets, or contracted engagements. Review the bounty scope before testing, and follow responsible disclosure practices for anything you find.
Why is the alg header a vulnerability?
Because attackers control it. If the server trusts the header to select the verification algorithm instead of pinning one, you can downgrade to none or swap RS256 for HS256—signing with the public key as an HMAC secret and forging arbitrary tokens.
What tools can crack JWT secrets?
hashcat with mode -m 16500, or john, brute-force the HMAC offline once you capture a single valid token—no interaction with the target required.
How long should a JWT stay valid?
Keep access-token TTLs short—minutes, not hours—paired with refresh tokens for session continuity, and always enforce exp server-side with a small clock tolerance.
Does Caido replace Burp Suite for JWT testing?
Caido offers comparable intercept, replay, fuzz, and automation capabilities in a Rust-based package. Every JWT technique in this guide works identically in either proxy—the methodology is tool-agnostic.
Related reading
- Weekly Threat Intel: 30 September 2026 — npm Malware, Typosquat Waves and Autumn CVEs
- Evilginx3 Lab: Build a Safe AiTM Phishing Lab to Understand Session Cookie Theft (and Why FIDO2 Stops It)
