| name | sast-jwt |
| description | Detect insecure JWT (JSON Web Token) implementations in a codebase using a two-phase approach: first map all JWT issuance and verification sites to understand the token lifecycle and signing configuration, then check each verification site for exploitable weaknesses such as algorithm confusion, missing signature verification, weak secrets, header injection, and missing claim validation. Requires sast/architecture.md (run sast-analysis first). Outputs findings to sast/jwt-results.md. If no JWT usage is found in Phase 1, Phase 2 is skipped. Use when asked to find JWT, token forgery, or authentication bypass bugs. |
JWT Vulnerability Detection
You are performing a focused security assessment to find insecure JSON Web Token (JWT) implementations. This skill uses a two-phase approach with subagents: recon (map the full JWT lifecycle — issuance, verification, and configuration) then analysis (identify every exploitable weakness in those verification sites).
Prerequisites: sast/architecture.md must exist. Run the analysis skill first if it doesn't.
What is an Insecure JWT Implementation
JWTs consist of three Base64URL-encoded parts: header.payload.signature. The header declares the signing algorithm (alg), the payload carries claims (e.g., sub, role, exp), and the signature is a cryptographic proof of integrity. Vulnerabilities arise when the server trusts the token's own claims about how it was signed, fails to verify the signature at all, uses a guessable secret, or trusts attacker-controlled key material embedded in the token itself.
The core pattern: the server does not fully verify the JWT's authenticity and integrity before trusting its claims.
What JWT Vulnerabilities ARE
1. Algorithm confusion — alg: none
The server accepts a JWT whose header declares "alg": "none", bypassing signature verification entirely. An attacker crafts an arbitrary payload, sets alg to none, and omits the signature. If the library processes it, the forged token is accepted.
2. Algorithm confusion — RS256 → HS256
A server configured for RS256 (asymmetric: sign with private key, verify with public key) can be tricked into HS256 mode if the library allows the algorithm to be specified by the token. Since the public key is often retrievable, the attacker signs a forged token with HS256 using the server's public key as the HMAC secret. The server verifies the HMAC using the same public key and accepts the token.
3. Missing or disabled signature verification
The server decodes the JWT payload without actually verifying the signature. Common patterns:
- Python (PyJWT):
jwt.decode(token, options={"verify_signature": False})
- Node.js (jsonwebtoken):
jwt.decode(token) instead of jwt.verify(token, secret)
- Manual base64 decode of the payload with no signature check
algorithms=["none"] accepted in the decode call
4. Weak or hardcoded HMAC secret
The server signs tokens with a short, guessable, or hardcoded secret (e.g., "secret", "password", "changeme", "jwt-secret-key"). An attacker who captures a valid token can brute-force the secret offline with tools like hashcat or jwt_tool, then forge arbitrary tokens.
5. Embedded JWK (jwk header injection)
The token header contains an embedded JSON Web Key (jwk parameter). If the verification code trusts the embedded key to verify the token's own signature, an attacker generates their own key pair, signs a forged token with their private key, and embeds their public key in the header. The server verifies the signature using the attacker's embedded public key and accepts the token.
6. JKU / X5U header injection
The jku (JWK Set URL) or x5u (X.509 certificate URL) header value is used to fetch the verification key from a URL. If the server does not validate the URL against an allowlist, the attacker can point it to their own server hosting a crafted key set.
7. Key ID (kid) header injection
The kid header is used to look up the signing key, often from a database or the filesystem. If the kid value is interpolated into a SQL query without sanitization, it becomes an SQL injection vector. If it is concatenated into a file path, it becomes a path traversal vector.
8. Missing claim validation
exp not checked → expired tokens remain valid forever
iss (issuer) not checked → tokens issued by other services are accepted
aud (audience) not checked → tokens intended for other services are accepted
nbf (not-before) not checked → tokens used before their valid window
9. No token revocation
There is no token blacklist or revocation mechanism. Stolen or logged-out tokens remain valid until they expire. This matters most when token lifetimes are long.
What JWT Vulnerabilities are NOT
Do not flag these as JWT vulnerabilities:
- IDOR: Changing a
user_id claim to access another user's data is an authorization flaw, not a JWT forgery — only flag if the token itself can be forged
- XSS via JWT payload: Injecting
<script> into a claim that is later rendered unescaped — that's XSS, not a JWT bug
- CSRF: JWT in cookies without
SameSite — that's a CSRF concern, not a JWT integrity issue
- Properly restricted verification:
jwt.verify(token, secret, { algorithms: ['HS256'] }) with a strong secret — not vulnerable
Patterns That Prevent JWT Vulnerabilities
1. Algorithm allowlist in verification call
payload = jwt.decode(token, secret, algorithms=["HS256"])
jwt.verify(token, secret, { algorithms: ['HS256'] })
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)
2. Strong, randomly generated secret
import secrets
SECRET_KEY = secrets.token_hex(32)
3. Full claim validation
payload = jwt.decode(
token, secret, algorithms=["HS256"],
options={"require": ["exp", "iss", "aud"]},
issuer="https://myapp.example.com",
audience="myapp-api"
)
4. Asymmetric keys with no algorithm ambiguity
jwt.verify(token, publicKey, { algorithms: ['RS256'] })
5. JWK/JKU URL allowlist
ALLOWED_JWKS_URLS = {"https://accounts.google.com/.well-known/jwks.json"}
if jku not in ALLOWED_JWKS_URLS:
raise ValueError("Untrusted JWK URL")
Vulnerable vs. Secure Examples
Python — PyJWT
def get_current_user(token: str):
payload = jwt.decode(token, options={"verify_signature": False})
return payload["user_id"]
def get_current_user(token: str):
payload = jwt.decode(token, SECRET_KEY)
return payload["user_id"]
SECRET_KEY = "secret"
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
SECRET_KEY = os.environ["JWT_SECRET"]
def get_current_user(token: str):
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return payload["user_id"]
Node.js — jsonwebtoken
function getUser(token) {
const payload = jwt.decode(token);
return payload.userId;
}
function getUser(token) {
const payload = jwt.verify(token, SECRET);
return payload.userId;
}
const SECRET = "password123";
jwt.verify(token, SECRET, { algorithms: ['HS256'] });
const SECRET = process.env.JWT_SECRET;
function getUser(token) {
const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
return payload.userId;
}
Java — jjwt
Jwts.parser().setSigningKey(key).parseClaimsJws(token);
Claims claims = Jwts.parserBuilder()
.setSigningKey(key).build()
.parseClaimsJws(token).getBody();
Claims claims = Jwts.parserBuilder()
.requireIssuer("myapp")
.requireAudience("myapp-api")
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Go — golang-jwt / dgrijalva/jwt-go
token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
var jwtKey = []byte("secret")
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtKey, nil
})
kid header SQL injection
def get_signing_key(kid):
result = db.execute(f"SELECT key FROM jwt_keys WHERE id = '{kid}'")
return result.fetchone()[0]
token_header = jwt.get_unverified_header(token)
key = get_signing_key(token_header["kid"])
jwt.decode(token, key, algorithms=["HS256"])
def get_signing_key(kid):
result = db.execute("SELECT key FROM jwt_keys WHERE id = %s", (kid,))
row = result.fetchone()
if not row:
raise ValueError("Unknown key id")
return row[0]
Embedded JWK injection
const { publicKey } = getPublicKeyFromHeader(decoded.header);
jwt.verify(token, publicKey);
const trustedKey = loadKeyFromConfig();
jwt.verify(token, trustedKey, { algorithms: ['RS256'] });
Execution
This skill runs in two phases using subagents. Pass the contents of sast/architecture.md to both subagents as context.
Phase 1: Map the JWT Lifecycle
Launch a subagent with the following instructions:
Goal: Map how the application creates, transmits, and verifies JWTs. Identify every JWT issuance and verification site, the library used, the signing algorithm and key/secret configuration, and the claims that are used for authorization. Write results to sast/jwt-recon.md.
Context: You will be given the project's architecture summary. Use it to understand the tech stack, authentication layer, and middleware patterns.
What to search for:
1. JWT library imports — identify which JWT library is in use:
- Python:
import jwt, from jose import, from authlib import, import python_jose
- Node.js:
require('jsonwebtoken'), import jwt from 'jsonwebtoken', jose, @nestjs/jwt
- Java:
io.jsonwebtoken, com.auth0.jwt, nimbus-jose-jwt
- Go:
github.com/golang-jwt/jwt, github.com/dgrijalva/jwt-go, github.com/lestrrat-go/jwx
- Ruby:
jwt gem (require 'jwt')
- PHP:
firebase/php-jwt, lcobucci/jwt
- C#:
System.IdentityModel.Tokens.Jwt, Microsoft.AspNetCore.Authentication.JwtBearer
2. JWT signing / issuance sites — where tokens are created:
jwt.encode(...), jwt.sign(...), Jwts.builder().signWith(...), JWT.create().sign(...)
- Note the algorithm used (
HS256, RS256, etc.) and where the secret/key comes from (env var, config, hardcoded)
3. JWT verification / decoding sites — where tokens are consumed:
jwt.decode(...), jwt.verify(...), Jwts.parserBuilder()...parseClaimsJws(...), JWT::decode(...)
- Note what options are passed:
algorithms, options, verify_signature, verify_exp
After Phase 1: Check for JWT Usage Before Proceeding
After Phase 1 completes, read sast/jwt-recon.md. If the summary states JWT is not used (no issuance or verification sites were found), skip Phase 2 entirely. Instead, write the following content to sast/jwt-results.md and stop:
# JWT Analysis Results
No JWT usage detected in this codebase.
Only proceed to Phase 2 if Phase 1 found at least one JWT verification site.
Phase 2: Analyze JWT Verification Sites for Vulnerabilities
Launch a second subagent after Phase 1 completes with the following instructions:
Goal: For each JWT verification site in sast/jwt-recon.md, determine whether it is exploitable. Check for algorithm confusion, missing signature verification, weak secrets, header injection attacks, and missing claim validation. Write final results to sast/jwt-results.md.
Context: You will be given the project's architecture summary and the Phase 1 recon output. Use both to understand the full token lifecycle before analyzing each site.
For each verification site, check the following:
Check 1 — Algorithm restriction
- Is the allowed algorithm explicitly specified in the verification call?
- If no algorithm restriction is present, can the token's
alg header be set to none to skip signature verification?
- If the server uses an asymmetric algorithm (RS256, ES256), does the verification code also accept HMAC algorithms (HS256)? If so, the server may be vulnerable to the RS256→HS256 confusion attack.
Check 2 — Signature verification enabled
- Is the token passed through a verify/parse call that actually checks the signature, or only through a decode-only call?
- Look for options like
verify_signature: False, complete=False, or the use of jwt.decode() (Node.js) instead of jwt.verify()
- Manual base64-decode of the payload without any signature check is always vulnerable
Check 3 — HMAC secret strength
- Is the secret hardcoded in source code? If so, is it a common word or short string?
- Is the secret loaded from an environment variable or config? Even then, note if the default or example value is weak
- A secret shorter than 32 characters or composed of dictionary words is likely brute-forceable
Check 4 — Embedded JWK / JKU / X5U header injection
- Does the verification code read the
jwk field from the token header and use it to verify the same token?
- Does the code fetch a key from a URL specified in the
jku or x5u header without validating the URL against an allowlist?
- If either is true, the verification is fully bypassable
Check 5 — kid header injection
- Is the
kid header value extracted from the token before verification and used to look up a key?
Important Reminders
- Read
sast/architecture.md and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- Phase 1 is purely discovery: locate every JWT issuance, verification, and configuration site. Do not attempt to assess security in Phase 1 — that is Phase 2's job.
- Phase 2 is purely analysis: for each verification site found in Phase 1, systematically check every vulnerability class. Do not search for new sites in Phase 2 — focus on what Phase 1 found.
- If no JWT usage is found in Phase 1, skip Phase 2 entirely and write a "No JWT usage detected" result file.
- The most critical checks are: signature verification disabled, algorithm not restricted (alg:none / RS256→HS256 confusion), and weak or hardcoded HMAC secret. These lead directly to full authentication bypass.
jwt.decode() in Node.js's jsonwebtoken library is a decode-only function — it never verifies the signature. Only jwt.verify() validates the signature. Confusing the two is a common and critical mistake.
- In Python's PyJWT, versions before 2.0 accepted
alg: none by default and did not require an algorithms parameter. If the codebase does not pin the version or restrict algorithms, flag it.
- Algorithm confusion (RS256→HS256) requires: (a) the server uses RS256 with a key pair, (b) the public key is accessible, and (c) the verification code does not restrict the algorithm. All three must be present.
kid injection is often overlooked: always check how the key lookup is implemented when kid is present in the token header.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.