JSON Web Tokens are popular because an API can check one without calling anyone: verify the signature, read the claims, done. That property is also the source of most JWT problems. A token you cannot look up is a token you cannot recall, and teams discover this the first time a laptop is stolen or an employee leaves.
This post walks through a complete JWT authentication flow with access tokens, refresh tokens and a sequence diagram, then covers the validation checks every API needs, where to keep tokens in a browser, refresh token rotation, key rotation and the realistic options for revocation.
What a JWT is, and what it is not
A JWT (RFC 7519) is a compact, URL-safe string of three base64url parts separated by dots: a header, a payload of claims and a signature. Decoded, an access token looks like this:
{ "alg": "RS256", "kid": "2026-10", "typ": "at+jwt" }
{
"iss": "https://auth.example.com",
"sub": "user_8412",
"aud": "orders-api",
"scope": "orders:read orders:write",
"iat": 1793606400,
"exp": 1793607300,
"jti": "a3f1c9e2"
}
Three points cause most misunderstandings:
- A signed JWT is not encrypted. Anyone holding the token can read the payload. Do not put secrets or personal data in it that the holder should not see.
- JWT is a format, not a protocol. How a client obtains a token is defined elsewhere, usually by OAuth 2.0 and OpenID Connect. The OAuth 2.0 flow diagram covers that part.
- Not every token in a flow is a JWT. Access tokens often are; refresh tokens are often opaque random strings that only the auth server can look up.
The registered claims you will meet in every token:
| Claim | Meaning | Who checks it |
|---|---|---|
iss |
Issuer, the auth server that signed the token | API |
sub |
Subject, usually the user ID | API, for authorization |
aud |
Audience, the API the token is meant for | API |
exp, nbf, iat |
Expiry, not-before and issue time, in seconds since the epoch | API |
jti |
Unique token ID, useful for denylists | API or gateway |
The flow, step by step
- Sign-in. The user authenticates with the auth server, typically through the OAuth authorization code flow with PKCE.
- Token response. The auth server returns a short-lived access token (a JWT, say 15 minutes) and a long-lived refresh token.
- API call. The app sends
Authorization: Bearer <access token>with each request. - Local validation. The API verifies the signature with the auth server's public key, which it fetched once from the JWKS endpoint and cached, then checks the claims. No call to the auth server per request.
- Expiry. After 15 minutes the API answers
401 Unauthorized. - Refresh. The app sends the refresh token to the auth server's token endpoint and gets a new access token and, with rotation, a new refresh token.
- Retry. The app repeats the failed request with the new access token.
Steps 3 and 4 are the reason teams pick JWTs: thousands of API requests and no shared session store. Steps 5 and 6 are the price, and where most of the security work sits.
Validating a token: the checklist
Every API that accepts JWTs should run the same checks, in this order:
- Allow only the algorithms you expect. Never take the
algheader at face value. RFC 8725 describes the two classic attacks:algset tonone, and an RS256 token rewritten as HS256 so a naive library verifies it with the public key as an HMAC secret. - Pick the key by
kidfrom the cached JWKS, and refetch the set when you see an unknownkid, at most once per short interval. - Verify the signature.
- Check
issandaud. A token issued for another API must fail here. - Check
expandnbfwith a small clock tolerance, such as 60 seconds. - Check the token type. RFC 9068 access tokens carry
typ: at+jwt, which stops an ID token from being replayed as an access token. - Then authorize: scopes, roles and ownership of the resource.
With the jose library in Node.js, most of the list is one call:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'))
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'orders-api',
algorithms: ['RS256'],
typ: 'at+jwt',
clockTolerance: 60,
})
return payload // scopes and sub are checked by the caller
}
How long should an access token live?
The lifetime is the window in which a stolen token works and cannot be recalled. Shorter means safer and more refresh traffic. For a user active for an eight-hour working day:
| Access token lifetime | Exposure if stolen | Refreshes per user per day |
|---|---|---|
| 5 minutes | 5 minutes | 96 |
| 15 minutes | 15 minutes | 32 |
| 1 hour | 1 hour | 8 |
| 24 hours | 24 hours | 1 |
Most teams land between 5 and 15 minutes for access tokens. A day-long access token is a session cookie that you cannot revoke.
Refresh token rotation
A refresh token lives for days or weeks, so leaking one is worse than leaking an access token. RFC 9700, the OAuth security best current practice, requires one of two protections for refresh tokens held by public clients such as browser apps and mobile apps:
- Sender-constrained refresh tokens, bound to a key the client holds, with mutual TLS or DPoP.
- Rotation: every refresh returns a new refresh token and invalidates the old one, while the auth server remembers the chain. If an attacker and the real client both use the same token, one of them presents an invalidated token, and the server revokes the active refresh token, which ends the chain for both.
Rotation turns a stolen refresh token from a weeks-long problem into one that ends the next time the legitimate client refreshes. The cost: the user has to sign in again, and your token endpoint needs to handle two tabs refreshing at the same time without tripping reuse detection, usually with a short grace period.
Where to keep tokens in a browser
| Storage | Readable by JavaScript | Main risk | Notes |
|---|---|---|---|
localStorage |
Yes | Any XSS can steal tokens | Survives reloads; avoid for refresh tokens |
| Memory only | Yes, while the page lives | XSS during the session | Lost on reload; refresh on startup |
HttpOnly cookie |
No | CSRF | Use Secure, SameSite, and CSRF tokens for state-changing requests |
| Backend for frontend | No; tokens stay on the server | Server-side session handling | The browser holds only a session cookie |
The IETF's draft guidance on OAuth for browser-based applications describes the backend-for-frontend pattern as the option that keeps tokens away from JavaScript entirely. If you keep tokens in the browser, keep the access token in memory and the refresh token in an HttpOnly cookie scoped to the token endpoint.
Revocation: what actually works
You cannot unsign a JWT. You can only make the API stop accepting it, and each option gives back some statelessness:
| Strategy | How it works | Cost |
|---|---|---|
| Short expiry | Tokens die on their own in minutes | None; the exposure window stays |
| Revoke the refresh token | The next refresh fails, so access ends at the next expiry | One lookup at the token endpoint, which already has state |
Denylist by jti |
Gateways reject listed IDs until their exp |
A shared cache, checked on every request |
| Per-user cutoff | Reject tokens issued before a stored timestamp for that user | One lookup per user, cacheable |
| Introspection | The API asks the auth server about each token (RFC 7662) | A network call; use it for high-risk endpoints only |
A practical combination: access tokens of 15 minutes or less, rotating refresh tokens revoked on logout and password change, and a per-user cutoff for the rare "sign out everywhere now" case.
Rotating signing keys
Keys leak and algorithms age, so plan rotation from day one. Publish the new public key in the JWKS a day or so before you sign with it, so every API cache has it. Switch signing to the new kid. Keep the old key in the JWKS until the last token signed with it has expired, then remove it. An API that refetches on an unknown kid survives an emergency rotation without a restart.
The JWT flow diagram
Diagram contents
- Browser app
- Auth server
- Orders API
- access token expired
- 1. Sign in (OAuth code flow + PKCE)
- 2. Access token (JWT, 15 min) + refresh token
- 3. GET /orders, Authorization: Bearer JWT
- GET /.well-known/jwks.json (once, then cached)
- public keys
- 4. 200 OK
- 5. GET /orders (expired JWT)
- 6. 401 Unauthorized
- 7. POST /token (refresh token)
- 8. New token pair, old refresh token invalidated
- 9. Retry GET /orders with new JWT
- 10. 200 OK
How to read it:
- Three lifelines. The browser app on the left, the auth server in the middle and the orders API on the right. Time runs downward.
- Login and token response at the top: the app gets an access token and a refresh token.
- The happy path: the bearer request to the API and the response. The API validates the token with cached JWKS keys and never calls the auth server.
- The opt fragment covers expiry:
401, refresh, a new token pair with the old refresh token invalidated, and the retried request. - The Markdown block shows the decoded token and the validation checks.
Drawing auth flows that reviewers can check
- Name every token on every arrow, with its type and lifetime: "access token (JWT, 15 min)". Ambiguity about which token travels where hides real bugs.
- Show where validation happens. Put a note on the lifeline that verifies the signature, and say where its keys come from.
- Draw the failure paths. Expiry, refresh reuse and logout belong in fragments; most incidents start there.
- Keep secrets out of the picture. Use placeholder values; diagrams get shared further than you expect.
The sequence diagram example covers lifelines and fragments in detail, and the zero trust architecture diagram shows where token checks sit in a wider access model.
Build it in Archboard
- Sign in with Google or GitHub and create a diagram.
- Add three Lifeline nodes from the Sequence pack: Browser app, Auth server and Orders API.
- Draw the messages as edges between lifelines, top to bottom: login, token response, the bearer request and its response. Label each with the token it carries.
- Add a Fragment from the Sequence pack with the operator
optaround the expiry path:401, refresh request, new token pair, retry. - Add a Markdown block from the Core pack with the decoded token and the validation checklist.
The example uses 5 diagram nodes, within the Free plan.
FAQ
Are JWTs encrypted? Signed JWTs are not. The payload is base64url-encoded and readable by anyone who has the token. JSON Web Encryption exists for encrypted tokens, but most access tokens are signed only.
Should I store a JWT in localStorage? Avoid it for anything long-lived. Any cross-site scripting bug can read localStorage. Keep access tokens in memory and refresh tokens in an HttpOnly cookie, or keep tokens on a backend for frontend.
How do I log out a user when JWTs are stateless? Revoke the refresh token so no new access tokens are issued, and keep access tokens short. For immediate effect, add a per-user cutoff timestamp or a jti denylist that your API checks.
What is the difference between an ID token and an access token? An ID token tells the client who signed in and is meant for the client. An access token is meant for an API. APIs should reject ID tokens; the typ and aud checks make that explicit.
Should I use HS256 or RS256? HS256 uses one shared secret, so every service that verifies tokens can also mint them. RS256 or ES256 lets APIs verify with a public key while only the auth server holds the private key, which is the safer default for more than one service.