JWT best practices: A guide to secure authentication
What to validate, what to avoid, and how to keep your tokens out of trouble.
JSON Web Tokens (JWTs) have become the default choice for representing claims between parties in modern applications. They're compact, self-contained, and work well across distributed systems. They're also easy to misuse in ways that quietly weaken your authentication system.
This guide covers the practices that matter most when you're putting JWTs into production: how to choose algorithms, what to validate, how to handle expiration and revocation, where to store tokens on the client, and the common pitfalls worth avoiding.
JWT 101
A JWT is three base64url-encoded segments separated by dots: a header, a payload, and a signature:
- The header declares the signing algorithm.
- The payload holds claims, which are statements about the subject (typically a user) and the token itself.
- The signature lets the recipient verify the token hasn't been tampered with.
The header and payload are encoded, not encrypted. Anyone with a JWT can read its contents by base64-decoding the middle segment. The signature only proves authenticity, not confidentiality.
Choose the right signing algorithm
The alg header field tells the verifier how to check the signature. Your choice here has real security consequences.
- Prefer asymmetric algorithms (RS256, ES256, EdDSA) when multiple services verify tokens. With asymmetric signing, the issuer holds a private key and verifiers only need the public key. A compromised verifier can't forge tokens. This matters in microservices architectures or when third parties need to verify tokens you issue.
- HS256 is fine for single-service setups. If the same service that issues tokens also verifies them, HMAC-SHA256 is simple and fast. The shared secret never leaves that boundary. The trouble starts when you give that secret to additional services: each one becomes able to mint tokens that look authentic.
- Never accept
"alg": "none". The JWT spec includes an "unsecured" mode where the signature is empty. Several historical vulnerabilities have come from libraries that happily accepted unsigned tokens because the server told them to. Configure your library to rejectnoneexplicitly, and use an allowlist of acceptable algorithms rather than a denylist of bad ones. A denylist that checks for the literal string"none"will miss"noNE","NoNe", and other capitalizations that some parsers happily accept. - Watch for algorithm confusion attacks. A classic JWT vulnerability: an attacker takes a token signed with RS256, changes the
algheader to HS256, and signs it using the server's public key as the HMAC secret. If your verification code passes the public key into a function that picks the algorithm based on the header, the forgery succeeds. The fix is to specify the expected algorithm explicitly when verifying, never trust the header to tell you what to do. - Distinguish signed tokens from encrypted ones. If your application accepts both JWS (signed) and JWE (encrypted) JWTs, make sure verification code can tell them apart and applies the right validation. CVE-2023-51774 covered a class of bug where libraries treated successful JWE decryption as equivalent to successful JWS signature validation. The decrypted payload was never authenticated, but the application acted as if it had been. Most applications only need signed JWTs; if that's you, configure your library to reject anything that isn't a JWS.
Validate every claim that matters
A signature check is not a complete validation. A token can be authentic and still be wrong for your application.
At minimum, verify:
exp(expiration time): reject expired tokens. Most libraries do this for you, but confirm it's enabled.nbf(not before): if present, the token isn't valid yet.iat(issued at): useful for sanity checks and for detecting tokens issued before a credential change.iss(issuer): confirm the token came from the expected issuer. If you accept tokens from multiple issuers, look the issuer up explicitly rather than trusting whatever appears in the field.aud(audience): confirm the token was meant for your service. A token issued for a different audience should not authenticate against yours, even if the issuer is the same.sub(subject): typically the user ID. Check that it resolves to a real account.
Allow a small clock skew when checking time-based claims (30 to 60 seconds is common). Server clocks drift, and rejecting tokens because of a few seconds of skew creates flaky authentication for no security gain.
In practice, all of this should be one call to a well-configured library. Using jose for Node.js:
The shape is the same in any modern JWT library. The point is to specify what you expect (algorithm, issuer, audience) rather than letting the token tell you.
Watch out for hostile header parameters
A few JWT header parameters tell the verifier where to fetch keys or certificates from, which is convenient and dangerous in equal measure.
- The
jku(JWK Set URL) andx5u(X.509 URL) headers can point to any URL. A server that blindly fetches whatever URL the token specifies is a server-side request forgery primitive: an attacker can probe internal services, hit cloud metadata endpoints, or trigger requests to arbitrary external hosts. If you support these headers at all, validate them against an allowlist of trusted issuer URLs. If allowlisting isn't practical, at minimum resolve the hostname and refuse to fetch from loopback or private IP ranges, and strip cookies from the outgoing request so your service's session cookies don't tag along. - The
kid(key ID) header is another input worth treating as untrusted. If you use it as a database key or LDAP filter, sanitize it. SQL injection and LDAP injection throughkidare real bug classes.
Prevent cross-JWT confusion
A single issuer often mints several kinds of JWT: access tokens, ID tokens, password reset tokens, security event tokens, and so on. They tend to share claims like sub and iss, and that overlap is what makes substitution attacks possible. A token issued for one purpose gets accepted for another, and now the attacker has access they shouldn't.
Audience validation is part of the answer, but not the whole answer. Two more techniques close the gap:
- Use the
typheader parameter to declare what kind of JWT this is. The updated JWT BCP (RFC 8725bis) recommends explicit typing for new JWT applications. Pick a media type for each kind of token (application/at+jwtfor OAuth access tokens,application/secevent+jwtfor security event tokens, and so on) and reject tokens whosetypdoesn't match what your endpoint expects. Withjose, this is one more option on the verification call:typ: 'at+jwt'. - Make validation rules for different token types mutually exclusive. If access tokens require an
audclaim and ID tokens require anonceclaim, the validation logic for one will reject the other. Different signing keys per token type achieves the same goal more aggressively: a key intended for ID tokens simply won't validate an access token.
Keep access tokens short-lived
Long-lived JWTs are a liability. Once issued, they're valid until they expire, and revocation is hard (more on that below). The shorter the lifetime, the smaller the window in which a stolen token is useful.
A typical pattern:
- Access tokens: 5 to 15 minutes. Used to authenticate API requests.
- Refresh tokens: hours to weeks, depending on the application. Used only to obtain new access tokens.
Refresh tokens should be opaque (random strings, not JWTs) and stored server-side so you can revoke them. When a refresh token is used, issue a new one and invalidate the old one. This pattern, called refresh token rotation, lets you detect token theft: if the same refresh token is presented twice, something is wrong, and you can revoke the entire session.
Storing tokens on the client
This is one of the most contested topics in JWT discussions, and the right answer depends on your threat model.
- httpOnly, Secure, SameSite cookies are the safest default for browser-based applications. JavaScript cannot read httpOnly cookies, which protects tokens from cross-site scripting. The Secure flag ensures the cookie only travels over HTTPS, and SameSite (Lax or Strict) blocks most cross-site request forgery vectors.
- localStorage and sessionStorage are vulnerable to XSS. Any script running on your page, including from a compromised dependency, can read them. If you go this route, your XSS prevention has to be airtight, and even then a single mistake hands tokens to an attacker.
- In-memory storage works for single-page applications that don't need to survive page reloads. The token lives in a JavaScript variable and disappears when the tab closes. This avoids both the XSS risk of localStorage and the CSRF concerns of cookies, at the cost of forcing a re-authentication on reload.
Native and mobile applications have their own secure storage mechanisms (Keychain on iOS, Keystore on Android), and you should use those rather than rolling your own.
!!For more on this, see JWT storage 101: How to keep your tokens secure.!!
Don't put sensitive data in the payload
The payload is base64-encoded, not encrypted. Treat anything you put in a JWT as visible to whoever holds the token, including the user themselves and anyone who intercepts it.
What this rules out:
- Passwords and password hashes
- API keys and secrets
- Personal information beyond what the user already knows about themselves
- Internal system details that could help an attacker
If you genuinely need to ship encrypted claims, JWE (JSON Web Encryption) exists for that purpose. In practice, most applications find it simpler to keep sensitive data server-side and reference it by ID.
Revocation is the hard part
JWTs are stateless by design, which is exactly what makes revocation difficult. The server doesn't keep a record of issued tokens, so there's no list to delete from when a user logs out or a token is compromised.
A few strategies, in roughly increasing order of complexity:
- Short expiration plus refresh tokens. The simplest approach. If access tokens last 5 minutes, the worst case window for a leaked token is 5 minutes. Revoking the refresh token (which is stored server-side) prevents new access tokens from being issued.
- Token denylist. Keep a list of revoked token IDs (the
jticlaim) in a fast store like Redis. On each request, check whether the token'sjtiis on the list. This reintroduces server state but only for the comparatively small set of revoked-but-not-yet-expired tokens. - Versioned claims. Include a counter or timestamp in the token (such as
auth_version) that matches a value on the user record. Bumping the value on the user record, during a password change for example, invalidates all existing tokens for that user. This requires a database lookup on each request, which partially defeats the point of stateless tokens.
Pick the approach that matches your security requirements. For most applications, short-lived access tokens with rotated refresh tokens is sufficient.
Key management
Your signing key is the keys to the kingdom. Anyone who has it can issue tokens that your system will accept as authentic.
- Use long, random keys. For HMAC algorithms, use at least 256 bits of entropy. For RSA, 2048 bits minimum, with 3072 or 4096 preferred. For elliptic curve, P-256 or higher.
- Store keys in a secrets manager. Environment variables are better than hardcoded values, but a proper secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager) gives you audit trails, rotation, and access control.
- Rotate keys. Periodic rotation limits the damage from a leaked key. Publish your verification keys at a JWKS endpoint with a
kid(key ID) header field so verifiers can pick the right key for each token. Old keys stay valid until tokens signed with them expire. - Bind each key to one algorithm. A single key should be used for exactly one algorithm (RS256, ES256, HS256, etc.), and your verification code should enforce this at use time, not just by convention. This is the structural fix for algorithm confusion attacks: even if an attacker swaps the
algheader, the key your server looks up is pinned to one algorithm and won't be used with any other. - Don't share signing keys across environments. Production, staging, and development should each have their own keys. A leak in development shouldn't compromise production.
Common pitfalls
A few mistakes that show up over and over:
- Trusting the
algheader. Always pin the expected algorithm in your verification code. Libraries that accept whatever the header says are how algorithm confusion attacks happen. - Skipping audience validation. If your tokens are issued by a shared identity provider, the same signature could be valid for many services. The
audclaim is what tells you the token was meant for you. - Putting refresh tokens in JWTs. Refresh tokens benefit from being opaque and revocable. Making them JWTs adds nothing useful and makes revocation harder.
- Treating JWT size casually. JWTs travel on every request. A 4 KB token of accumulated claims wastes bandwidth and can break header size limits in some servers and proxies. Keep payloads small.
- Forgetting to validate the signature. It happens. A bug or shortcut path that skips verification leaves you with what is effectively an unsigned bearer token. Audit the verification flow.
- Storing JWTs with no expiration. Some libraries default to no
expclaim if you don't set one. Always set an expiration. - Mismatched JWS serialization formats. JWTs are required to use the Compact Serialization (three base64url segments separated by dots). Some libraries can verify both Compact and JSON Serialization, which becomes a problem if verification accepts a JSON-Serialized JWS but downstream code splits the token by dots to extract claims. The two paths see different data, and an attacker can exploit the gap. Reject anything that doesn't look like a compact JWT before you try to verify it.
Use battle-tested libraries
Don't implement JWT signing or verification yourself. Use well-maintained libraries for your platform: jose for Node.js, PyJWT or python-jose for Python, jsonwebtoken for Go, and so on. These libraries have been audited and have fixes for the algorithm confusion attacks and other historical vulnerabilities. Roll-your-own implementations rarely catch all the edge cases.
Configure them strictly. Pin the algorithm. Require expiration. Validate audience and issuer. Most libraries support all of this; the question is whether you've turned it on.
How WorkOS handles this
If you're building authentication from scratch, every item in this guide is something you need to get right and keep right. WorkOS AuthKit issues access tokens as JWTs signed with asymmetric keys, published at a JWKS endpoint your backend fetches and caches. Refresh tokens are single-use, which means refresh token rotation is the default rather than something you have to wire up. The Next.js and Remix SDKs validate access tokens and refresh expired ones for you. For other frameworks, the same primitives work through jose against the WorkOS JWKS endpoint, with verification code that looks essentially identical to the snippet above.
Even with that in place, the practices in this guide are still worth knowing. A token going wrong looks the same whether you minted it yourself or got it from an identity provider, and being able to decode a payload and reason about what should and shouldn't validate makes integration and debugging much easier.
Closing thoughts
JWTs do one thing well: they let services share verifiable claims about a subject without coordinating on shared state. That's genuinely useful, and most authentication systems benefit from it.
The failure modes come from treating JWTs as more than they are. They're not encrypted, they're not easily revocable, and they're not magic. Treat them as signed messages with a short shelf life, validate everything, and keep your keys safe. The rest follows.