A JWT is a signed bundle of JSON claims. Here is how to read one, why decoding is not verifying, and how to check for the classic JWT security flaws.
Password entropy measures how hard a secret is to guess, in bits. Here is the formula, why Password1! fails it, and how to generate something strong.
9 min read
A hash is a fixed-length fingerprint of data. Here is how MD5 and SHA-256 differ, why fast hashes are wrong for passwords, and what HMAC adds.
10 min read
A JWT (JSON Web Token) is a compact, signed bundle of JSON claims, usually something like "this is user 123, valid for 15 minutes". RFC 7519 defines it. You'll find JWTs in Authorization: Bearer headers, cookies, OAuth access tokens and OpenID Connect ID tokens. The format is simple. The mistakes around it are the reason it shows up in so many pentest reports.
Have a token to inspect? The JWT Debugger splits a token into header, payload and signature. The JWT Decoder & Algorithm Review also flags risky headers and claims. Use test or expired tokens only.
The outputs below were captured on 11 October 2026 (IST) on a Linux box with Python 3.13 and PyJWT 2.15.1. The token is made up for this article. Its secret is a throwaway test string, and the domain uses the reserved .test TLD.
A signed JWT has three parts joined by dots. Here's the test token:
eyJhbGciOiJIUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS50ZXN0Iiwic3ViIjoidGVzdC11c2VyLTEyMyIsImF1ZCI6ImRlbW8tYXBpIiwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDA5MDAsInJvbGUiOiJ2aWV3ZXIifQ.u53lWCrV0JAphmUV16XX6IytTlIuqkJhJrH4zhgmFHg
Each part is base64url: the URL-safe Base64 alphabet (- and _ instead of + and /) with the trailing = padding removed, per RFC 7515. That's why a normal Base64 decoder sometimes chokes on a JWT. Add the padding back, or use the Base64 tool, which accepts both alphabets.
Decoding needs no key at all:
python3 -c "
import base64, json
h, p, s = open('token.txt').read().strip().split('.')
pad = lambda x: x + '=' * (-len(x) % 4)
print(json.loads(base64.urlsafe_b64decode(pad(h))))
print(json.loads(base64.urlsafe_b64decode(pad(p))))
print('signature bytes:', len(base64.urlsafe_b64decode(pad(s))))
"
{'alg': 'HS256', 'kid': 'test-key-1', 'typ': 'JWT'}
{'iss': 'https://auth.example.test', 'sub': 'test-user-123', 'aud': 'demo-api', 'iat': 1760000000, 'exp': 1760000900, 'role': 'viewer'}
signature bytes: 32
The signature is 32 bytes because HS256 is HMAC with SHA-256. Paste the same token into the JWT Debugger and you see the same three parts.
alg: the algorithm used to sign it (HS256, RS256, ES256 ...). Algorithm names are defined in RFC 7518 (JWA).typ: the media type, usually JWT.kid: a key ID, a hint for which key verifies the token.Other headers point at keys: jku (a URL to a key set), jwk (an embedded key) and x5u/x5c (certificates). Keys and key sets have their own format, JWK, defined in RFC 7517.
Here's the problem in one line: the header that tells you how to verify the token arrives inside the token, and whoever sent it controls it. Most classic JWT bugs come from a server believing that header.
RFC 7519 §4.1 registers seven claim names. All are optional, and the application decides which it requires.
| Claim | Meaning |
|---|---|
iss | Issuer: who made the token |
sub | Subject: who it's about (often a user ID) |
aud | Audience: who it's meant for |
exp | Expiration time: must not be accepted on or after this |
nbf | Not before: must not be accepted before this |
iat | Issued at |
jti | JWT ID: a unique ID for the token |
Anything else, like role above, is a public or private claim the application defines. Time claims are NumericDate values: seconds since the Unix epoch, not milliseconds. The test token's exp of 1760000900 is 15 minutes after its iat. For the seconds-vs-milliseconds trap and how exp/iat/nbf show up in investigations, see Unix epoch time explained or convert values in the timestamp converter.
Anyone holding a JWT can read its payload. A signed JWT is a JWS: it gives you integrity, not secrecy. The OWASP JWT Cheat Sheet says it plainly: the payload is "only base64url encoded, not encrypted." So never put passwords, API keys or personal data you wouldn't print in a log into a JWT.
If the claims must stay secret, the encrypted form is JWE (RFC 7516). A JWE in compact form has five dot-separated parts, not three.
Verifying means checking the signature with the right key and algorithm, then checking the claims. Here's PyJWT verifying the test token under different rules:
python3 -c "
import jwt
t = open('token.txt').read().strip()
secret = 'TEST-ONLY-fake-secret-do-not-use-0123456789abcdef'
checks = [
('wrong algorithm', dict(key=secret, algorithms=['RS256'])),
('expired', dict(key=secret, algorithms=['HS256'], audience='demo-api')),
('wrong audience', dict(key=secret, algorithms=['HS256'], audience='other-api', options={'verify_exp': False})),
('valid (exp check off for demo)', dict(key=secret, algorithms=['HS256'], audience='demo-api', issuer='https://auth.example.test', options={'verify_exp': False})),
]
for name, kw in checks:
try:
print(name, '->', jwt.decode(t, **kw))
except jwt.PyJWTError as e:
print(name, '->', type(e).__name__ + ':', e)
"
wrong algorithm -> InvalidAlgorithmError: The specified alg value is not allowed
expired -> ExpiredSignatureError: Signature has expired
wrong audience -> InvalidAudienceError: Audience doesn't match
valid (exp check off for demo) -> {'iss': 'https://auth.example.test', 'sub': 'test-user-123', 'aud': 'demo-api', 'iat': 1760000000, 'exp': 1760000900, 'role': 'viewer'}
The token expired in 2025, so a real check rejects it. Only the last line turns expiry checking off, to show what a passing result looks like. Don't do that in production.
OWASP's cheat sheet makes the same split: with a MAC, a valid signature only proves some holder of the shared secret made the token.
Every item below is in RFC 8725, the JWT Best Current Practices, or the OWASP cheat sheet. Each one is a review question, not a recipe. The JWT Decoder & Algorithm Review is a quick first pass on the header and claims.
1. Accepting alg: none. RFC 7519 defines "Unsecured JWTs" with "alg":"none" and an empty signature. RFC 8725 §2.1 notes that some libraries trusted that value and skipped the signature check. Check: does the verifier take an allowlist of algorithms? Fix: pin it. Here's PyJWT given RFC 7519's own unsecured example and an HS256-only allowlist:
{'alg': 'none'}
InvalidAlgorithmError: The specified alg value is not allowed
2. Algorithm confusion (RS256 to HS256). RFC 8725 describes libraries that, given a token claiming HS256, used the server's RSA public key as the HMAC secret. Since the public key is public, the signature means nothing. Fix: RFC 8725 §3.1 says each key "MUST be used with exactly one algorithm." Pass an explicit algorithms list and keep RSA keys out of HMAC code paths.
3. Weak HMAC secrets. A short or human-memorable secret can be brute-forced offline from any one token (RFC 8725 §2.2). RFC 7518 §3.2 says a key "of the same size as the hash output (for instance, 256 bits for "HS256") or larger MUST be used." RFC 8725 §3.5 adds that passwords must not be used directly as HMAC keys. Fix: generate at least 32 random bytes from a CSPRNG, store them in a secrets manager and rotate them.
4. Trusting kid, jku or jwk. A server that fetches whatever jku points to, or verifies with a key embedded in the token, lets the sender pick the key. kid values that reach SQL or LDAP lookups are an injection path too (RFC 8725 §3.10). Fix: resolve keys only from a configured key set or issuer metadata, use kid only to choose among keys you already trust, and match any key URL against an allowlist.
5. Not validating exp, nbf, aud or iss. A valid signature isn't the whole check. RFC 8725 §3.8 and §3.9 require checking that the keys belong to the issuer and, where one issuer serves several apps, that aud matches. Fix: require exp, set audience and issuer in your library call (as above), and keep clock-skew leeway to a few minutes, as RFC 7519 suggests.
6. Long-lived tokens with no revocation. A JWT stays valid until exp, even after logout or a password change. OWASP lists short expiry, denylists keyed on jti and iss and token status lists as options. Fix: keep access tokens short, use refresh tokens you can revoke server-side, and log jti so you can trace misuse.
For testing these in an engagement, the API security testing guide covers JWT checks in an API pentest. The Web & API pentesting masterclass is the hands-on next step, in a lab you own.
A classic session cookie is a random ID that the server looks up, so revoking it is instant. A JWT carries its own claims, so servers can verify it without a lookup. You trade that for harder revocation and bigger requests. Neither is "more secure" by default.
Storage matters more than format. OWASP's HTML5 Security Cheat Sheet says: "Do not store session identifiers in local storage as the data is always accessible by JavaScript," and that cookies can reduce this risk with HttpOnly. One XSS bug can read localStorage. An HttpOnly, Secure, SameSite cookie keeps the token away from scripts, but browsers send cookies automatically, so you then need CSRF protections.
JWTs also show up as OpenID Connect ID tokens. The OAuth 2.0 and OIDC guide covers how those get validated.
Usually not. A normal three-part JWT is signed (JWS), so anyone can read it. Encrypted JWTs use JWE and have five parts.
Yes. Decoding is just base64url. The secret or public key is only needed to verify the signature.
Only paste test or expired tokens. A live token is a credential: anyone who copies it can use it until exp. Treat production tokens like passwords.
JWT is the claims format (RFC 7519). JWS is the signed wrapper (RFC 7515), and JWE is the encrypted one (RFC 7516). Most "JWTs" you meet are JWS.
Share this article
An IPv4 address is one 32-bit number. Here is how to read it as decimal, hex, binary or integer, size a CIDR block, and spot private ranges.
9 min read
Sign in to leave a comment.