How JWT Authentication Actually Works: Base64url, HMAC Signatures, and the alg:none Attack
JSON Web Tokens are everywhere: OAuth2 access tokens, session cookies in SPAs, API-to-API auth. Most developers can recite "header, payload, signature" โ and then get every security-critical detail wrong. A JWT is not encrypted. Its signature scheme is chosen by an attacker-controlled header field. And libraries have shipped bugs that let anyone mint admin tokens with no key at all.
This article takes a JWT apart byte by byte, so you understand exactly what is signed, what is not, and where the real attacks live.
Anatomy: Three Base64url Segments
A JWT is a string of the form xxxxx.yyyyy.zzzzz โ three segments separated by dots:
- Header โ JSON describing the token type and signing algorithm.
- Payload โ JSON with claims (statements about the subject).
- Signature โ cryptographic proof the first two segments are unmodified.
Each segment is base64url-encoded (RFC 4648 ยง5), not standard base64. Base64url replaces + with - and / with _, and omits padding (=). This makes tokens URL-safe: they can sit in query strings and HTTP headers without percent-encoding.
Take this real example. Header JSON:
{"alg":"HS256","typ":"JWT"}
Base64url-encode it (UTF-8 bytes โ base64url, no padding):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload JSON:
{"sub":"1234567890","name":"John Doe","iat":1516239022}
becomes:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
The signature is computed over the ASCII string header_segment + "." + payload_segment โ the encoded forms, not the JSON. With HMAC-SHA256 and secret your-256-bit-secret:
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Full token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
You can verify this yourself: paste any segment into a base64url decoder and you get the JSON back. Anyone can read the payload. There is no encryption here โ only a signature proving integrity. If you need confidentiality, you need JWE (JSON Web Encryption), a different and heavier standard.
The Claims That Matter (RFC 7519)
The payload carries claims โ name/value pairs asserting something. RFC 7519 registers seven standard claims; three of them are load-bearing for security:
| Claim | Name | Purpose |
|---|---|---|
iss |
Issuer | Who created the token |
sub |
Subject | Who the token is about (user ID) |
aud |
Audience | Who the token is for (your API's identifier) |
exp |
Expiration | Unix timestamp after which the token is invalid |
nbf |
Not before | Unix timestamp before which the token is invalid |
iat |
Issued at | When the token was created |
jti |
JWT ID | Unique token identifier (useful for revocation lists) |
A verifier must check more than the signature. At minimum:
expis in the future (with a small leeway, typically 30โ60 seconds, for clock skew).audmatches your service's identifier โ otherwise a token minted for service A is accepted by service B.issmatches the expected issuer โ otherwise anyone's signing key is trusted.
Skipping aud/iss validation is one of the most common real-world JWT bugs. The signature only proves someone with the key signed it; the claims prove it was meant for you.
HMAC vs RSA vs ECDSA: Who Holds the Key
The alg header field names the signature algorithm. The three families you will meet:
HS256 / HS384 / HS512 โ HMAC with SHA-2. Symmetric: the same secret signs and verifies. Simple and fast, but every verifier must hold the secret. If your API gateway and three microservices all verify tokens, all four hold the signing secret โ and any one of them can mint tokens. Fine for a single service; questionable for distributed systems.
RS256 / RS384 / RS512 โ RSASSA-PKCS1-v1_5 with SHA-2. Asymmetric: a private key signs, a public key verifies. The standard choice for OAuth2/OIDC providers (Google, Auth0): the provider keeps the private key; everyone else fetches the public key from a JWKS endpoint. Verifiers cannot forge tokens.
ES256 / ES384 โ ECDSA with P-256/P-384 and SHA-2. Asymmetric like RSA but with much smaller keys and faster signing. Growing in popularity; slightly trickier to implement correctly (ECDSA requires a unique random nonce per signature โ reuse leaks the private key).
There is also EdDSA (Ed25519) in newer deployments โ fast, deterministic nonces, small signatures. If you are choosing today and control both ends, Ed25519 is an excellent default.
Worked Example: Verifying an HS256 Token by Hand
You can verify the example token above with OpenSSL, no JWT library needed:
# Recompute the signature over "header.payload"
echo -n 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ' \
| openssl dgst -sha256 -hmac 'your-256-bit-secret' -binary \
| openssl base64 -A | tr '+/' '-_' | tr -d '='
# Output: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
It matches the token's third segment. That is the entire verification primitive: HMAC-SHA256 over the ASCII header.payload, base64url-encoded without padding. Libraries add claim validation on top.
The alg:none Attack: When the Attacker Picks the Algorithm
Here is the design flaw that keeps JWT interesting: the alg value comes from the token itself โ attacker-controlled input. In 2015, researchers found that several widely used libraries (including Auth0's java-jwt and node-jsonwebtoken versions of the era, CVE-2015-9235) would honor "alg":"none" in the header and skip signature verification entirely.
The attack:
- Take a valid token, decode the payload, change
"sub"to an admin user ID. - Set the header to
{"alg":"none","typ":"JWT"}and drop the signature segment. - Send
newheader.newpayload.(note the trailing dot, empty signature).
A vulnerable verifier reads alg from the header, sees none, and accepts the token with no cryptographic check. Full authentication bypass, no key needed.
The fix โ applied by every maintained library since โ is to never trust the header's alg: the verifier must specify an allowlist of expected algorithms (ideally exactly one, e.g. only RS256) and reject anything else, including none. If you use a JWT library, check that you pass an explicit algorithm allowlist; the dangerous pattern is any API like verify(token, key) that infers the algorithm from the token.
A sibling attack: algorithm confusion (RS256 โ HS256). If a server publishes its RSA public key and a verifier accepts HS256, the attacker signs with HS256 using the public key as the HMAC secret โ the public key is public, so the attacker knows it. Again, the defense is an explicit algorithm allowlist: if you expect RS256, reject HS256 outright.
Common Mistakes in Production
1. Storing tokens in localStorage. Any XSS vulnerability becomes full account takeover โ JavaScript can read localStorage. Prefer HttpOnly, Secure, SameSite cookies for browser sessions, which JavaScript cannot touch.
2. Putting secrets in the payload. The payload is base64url, not encrypted. Passwords, PII, API keys in claims are readable by anyone who sees the token (and tokens leak into logs constantly). Keep claims to identifiers and metadata.
3. No expiration โ or absurdly long ones. A token without exp is valid until the signing key rotates, which may be never. Access tokens should live minutes to hours; use short-lived access tokens plus rotating refresh tokens.
4. Trusting the header's alg. Covered above: pin your expected algorithm(s) in the verifier. Reject none unconditionally.
5. Weak HMAC secrets. HS256 with secret = "password123" falls to offline brute force in seconds โ the attacker has the token and can test guesses locally at billions of hashes per second. Use a cryptographically random secret of at least 256 bits, stored in a secrets manager, never in source control.
6. Not validating aud and iss. A token minted for your staging environment or a partner service gets accepted in production. Always check both.
7. Rolling your own JWT parsing. Splitting on . and base64-decoding looks trivial, but the edge cases (algorithm handling, claim validation, timing-safe comparison, key management) are where vulnerabilities live. Use a maintained library and configure it strictly.
Debugging Walkthrough: "Valid Token, 401 Anyway"
A typical session: your token looks right, the signature verifies in a debugger, but the API returns 401. Work through this checklist in order:
- Clock skew. Decode the payload and compare
exp/nbfagainst current Unix time. Ifexpis in the past by seconds, the issuer and verifier clocks disagree โ add leeway or sync with NTP. - Audience mismatch. Print the
audclaim. It must exactly equal the identifier your API expects โhttps://api.example.comโapi.example.com. - Key mismatch. For RS256, confirm the verifier is using the current public key. Providers rotate keys; if you cached a JWKS response, you may be verifying against a retired key. Check the token's
kid(key ID) header against the JWKS. - Algorithm pinning. If you recently hardened the verifier to only accept RS256, tokens signed with HS256 during a migration window will now fail โ which is the intended behavior, but it looks like an outage. Coordinate rotation.
- Whitespace. Tokens copied from logs or emails sometimes gain trailing newlines or spaces. Trim before verifying โ a single
\nchanges the signed bytes.
Try It
Reading about base64url is one thing; seeing the segments decode is another. DailyToolbox's free JWT decoder runs entirely in your browser โ paste a token to inspect its header and payload, check claim timestamps, and verify signatures without the token ever leaving your machine.
๐ Decode a JWT โ
FAQ
Is a JWT encrypted?
No โ a standard JWT (JWS, JSON Web Signature) is only signed. The header and payload are base64url-encoded, which is an encoding, not encryption; anyone can decode them. If you need confidentiality, use JWE (JSON Web Encryption), which actually encrypts the payload.
What's the difference between JWS, JWE, and JWT?
JWT is the general term for the token format. JWS (RFC 7515) defines signed tokens โ what most people mean by "JWT". JWE (RFC 7516) defines encrypted tokens. A JWT can be a JWS, a JWE, or a nested combination.
Should I use sessions or JWTs for my web app?
For a traditional server-rendered app, server-side sessions in HttpOnly cookies are simpler and easier to revoke. JWTs shine for stateless APIs, microservices (no shared session store), and mobile clients. The worst of both worlds is a JWT stored in localStorage acting as a session โ you get XSS exposure without revocation.
How do I revoke a JWT before it expires?
JWTs are stateless by design, so there's no built-in revocation. Options: keep lifetimes short (minutes), maintain a denylist of jti values checked on each request (reintroduces state), or rotate the signing key (revokes everything). Pick based on how quickly you need revocation to take effect.
Why does my library reject a token with "invalid algorithm"?
Your verifier is pinning an algorithm allowlist and the token's alg header isn't on it. This is the correct secure behavior โ it blocks alg:none and algorithm-confusion attacks. Fix it by signing with the expected algorithm, not by loosening the allowlist.