Security Cluster
JWT Security Deep Dive: Signing, Validation, Refresh
JWT is the authentication token that everyone uses and almost nobody implements correctly on the first try. The spec is short, the libraries are mature, and yet every year there is a new public CVE about a JWT verifier that accepted alg: none, trusted the kid header as a filesystem path, or confused HMAC and RSA keys. The failures are rarely in the math of JWT. They are in the part of the code that decides whether to trust a token.
This guide covers what a JWT actually is on the wire, the claims that matter for security, the algorithms you should use and the ones you should not, the validation checklist every verifier has to run, and the refresh token pattern that makes long-lived sessions survivable.
Anatomy of a JWT
A JWT is three base64url-encoded strings separated by dots: header.payload.signature. The header is a JSON object describing the algorithm and token type. The payload is a JSON object of claims — statements about the subject. The signature is a keyed hash of header.payload that proves the token was issued by someone with the signing key.
The authoritative spec is RFC 7519. It is short. Read it once before touching JWT code. For the security implications of design choices, RFC 8725 (JWT Best Current Practices) is the shorter follow-up every implementer should also read.
To see what is inside a token without trusting it, use JWT Decoder. Decoding is not validation — you can read the header and payload of any JWT without any key. That is a feature of the format, not a bug. Never put secrets in the payload, because anyone holding the token can read them.
To generate test tokens for your verifier, JWT Generator lets you build a signed token with a custom header, payload, and HMAC key. Pair the two tools when writing or debugging an auth flow.
Claims that matter
JWT defines seven "registered" claims in RFC 7519. They have three-letter names that look cryptic the first time but every library and every spec uses the same ones.
- iss (issuer): who signed the token. Your verifier should check this matches an expected issuer.
- sub (subject): the user or entity the token represents. Usually an opaque ID.
- aud (audience): who the token is for. Your verifier should check that it is in the list, otherwise you will accept tokens issued for a different service.
- exp (expiration): unix timestamp after which the token is invalid. Always check.
- nbf (not before): unix timestamp before which the token is not valid. Useful for deferred activation.
- iat (issued at): unix timestamp when the token was issued. Useful for logging but not a validation control on its own.
- jti (JWT ID): unique identifier for the token. Useful for revocation lists.
Missing exp is the single most common JWT mistake. A token without expiration is valid forever, and if it leaks, there is no automatic way to invalidate it. Set an expiration, and make it short enough that a leaked token is not catastrophic.
Algorithms, HMAC vs RSA vs EdDSA
JWT supports several signing algorithms. In practice, three matter.
HMAC (HS256, HS384, HS512). Symmetric. Same key is used to sign and verify. Fast, simple, and fine for a single service that issues and verifies its own tokens. If multiple services need to verify the token, they all need the key — and a leaked key is a compromise of the entire issuer.
RSA (RS256, RS384, RS512). Asymmetric. A private key signs; a public key verifies. Slower but lets you distribute the public key widely and keep the private key on one system. This is the standard for OIDC and any case where multiple parties verify the same tokens.
EdDSA (Ed25519). Modern asymmetric signature. Faster than RSA, smaller keys, no parameter choices to get wrong. The current recommendation for new systems that need asymmetric crypto, though library support varies.
The one you should never accept: alg: none. It is specced — it means "no signature" — and early JWT libraries would verify tokens with alg: none as valid. That is the exact vulnerability behind the classic JWT bypass attack. Modern libraries reject it by default. Check yours.
For HMAC keys, use HMAC Generator to test signing independently of your library, and Hash Generator for the raw hash operations. A good HMAC secret is at least 256 bits of random data, generated with a CSPRNG — not a human-memorable password.
The validation checklist
When your verifier accepts a token, it must answer "yes" to every item on this list. If any is skipped, the door is open.
- Is the signature valid? Verify against the expected key. For asymmetric, fetch the JWKS from the issuer and pick the key matching
kid, but only from the trusted source — never follow a URL in the token itself. - Is the algorithm one you accept? Do not rely on the header's
algfield to decide how to verify. Hard-code the expected algorithm on the verifier side, or the "algorithm confusion" attack lets an attacker sign with HMAC using the RSA public key as the secret. - Is
expin the future? With a small clock skew tolerance if needed (30 seconds is typical). - Is
nbfin the past? If present. - Does
issmatch? Against a hard-coded allowlist of expected issuers. - Does
audcontain your service? Reject tokens that were not issued for you. - Is the token on a revocation list? If you maintain one.
The OWASP JWT Cheat Sheet walks through each of these with code-level examples. It is the right reference to check your implementation against.
Refresh tokens done right
The short-lived access token plus long-lived refresh token pattern balances two goals: minimize exposure when a token leaks, and avoid forcing users to re-authenticate every five minutes.
The access token expires quickly (5–15 minutes typical). The refresh token lives longer (hours to days to weeks, depending on the app) and is used to request new access tokens. When you issue a new access token from a refresh token, rotate the refresh token too — issue a fresh one and invalidate the old. If an old refresh token is ever used after rotation, assume compromise and revoke the session.
Store refresh tokens in HTTP-only, Secure, SameSite cookies so JavaScript cannot read them. Store access tokens in memory, not localStorage, because localStorage is reachable by any XSS. The combination of HTTP-only refresh and in-memory access is the standard pattern for browser SPAs today.
Tokens often arrive as Authorization: Bearer <token> headers. When debugging, decode the part after "Bearer" with Base64 Encoder/Decoder if you need to look at pieces manually, though JWT Decoder is faster for full tokens.
Vulnerabilities that still ship
The alg: none accept
The classic: a verifier reads the algorithm from the header and, on seeing none, skips verification. Every library with this default has been patched, but legacy code and new wrappers still reproduce it.
Algorithm confusion (HS256 vs RS256)
The verifier is set up to handle RSA. An attacker sends a token with header alg: HS256 and signs it using the verifier's public RSA key as an HMAC secret. Naive libraries "verify" and accept. The fix: hard-code the expected algorithm at the call site; never trust the header.
Key confusion via kid injection
The kid (key ID) header can be misused. Some verifiers blindly read kid from the header and use it to look up or fetch a key (file path, database row, URL). Path traversal, SQL injection, or SSRF follow. Always treat kid as untrusted input and sanitize strictly.
Missing expiration
Tokens without exp live forever. Reject them on the verifier side even if you cannot control the issuer.
Long-lived refresh tokens without rotation
A leaked refresh token without rotation is a persistent backdoor. Rotate on every use and detect reuse as a compromise signal.
JWTs in the URL
Never put tokens in query strings. They end up in server access logs, proxy logs, browser history, and Referer headers sent to third parties. Always use Authorization headers or cookies.
Adjacent tools worth bookmarking
Security-adjacent tools: Password Generator for HMAC secrets and test credentials, UUID Generator for jti values, HTTP Security Headers to audit the broader auth surface, and CSP Header Generator for the Content-Security-Policy that limits what a compromised script can do with any tokens it finds.
Related pillar guide
This cluster belongs to the security track. For a broader intro to browser-based web security tools, see Web Security Tools: A Beginner's Guide.
FAQ
Can I use JWT for session tokens in a web app?
You can, but traditional server-side sessions are often simpler. JWTs shine when the token needs to be verified by services that cannot talk to a shared session store. If everything is one backend, a plain session ID in a cookie may be less error-prone.
Is JWT encrypted?
Not by default. A JWT is signed, not encrypted. Payloads are base64url-encoded, which is trivially reversible. If you need confidentiality, use JWE (JSON Web Encryption) or wrap the token in HTTPS only and keep secrets out of the payload.
How long should a JWT access token live?
Short: 5 to 15 minutes is typical. Shorter means a leaked token is less dangerous but more refresh traffic. Longer means less refresh traffic but higher exposure. 15 minutes is a common default.
What should I put in the sub claim?
A stable, opaque user ID — not an email or username, which may change. Use the internal user identifier from your database.
Can I verify a JWT without calling my server?
With asymmetric signing and a public key distributed via JWKS, yes — any party that holds the public key can verify. This is how OIDC tokens work across services.
Closing thought
JWT is not complicated. The trap is that it is easy to make work in the happy path and easy to accept invalid tokens in the failure paths. Write down the validation checklist, run every verifier against it, and treat any shortcut with suspicion. The CVEs in this space are boring and repetitive, which is its own reminder.