Volley / Templates / JWT refresh token rotation

Sign-in template

JWT refresh token sequence diagram

Access tokens last 15 minutes, and the refresh token is replaced each time it is used. The diagram covers login, the 401 when the access token expires, the refresh, and what happens when an old refresh token shows up again.

Numbered sequence diagram of JWT access and refresh tokens across User, Browser, Auth Server, Token Store and API: login, an API call with a bearer token, a 401 after expiry, POST /token/refresh with rotation and a retry, or reuse detected and the token family revoked.

Click any arrow in the editor to see its request and response as a table or the raw body. Change the hosts, ids and fields to match your integration, then share a link or embed it in your README.

Step by step

  1. The user signs in. The auth server returns a JWT access token in the body and sets the refresh token as an HttpOnly, Secure cookie scoped to /token/refresh.
  2. The browser calls the API with Authorization: Bearer. The API checks the signature, exp, iss and aud itself, with no call to the auth server.
  3. The access token expires and the API answers 401 with WWW-Authenticate: Bearer error="invalid_token".
  4. The browser posts to /token/refresh and the cookie goes with it. The auth server marks that refresh token used, issues a new pair and sets the new cookie.
  5. The browser retries the original call with the new access token.
  6. If a used refresh token comes back, the auth server revokes every token in its family and the user signs in again.

Easy to get wrong

  • Store only a hash of each refresh token, so a leaked database cannot be replayed.
  • Two tabs refreshing at once can look like reuse. Share one refresh across tabs, or allow a few seconds of grace for the token just replaced.
  • A JWT stays valid until exp even after sign-out. Keep access tokens short instead of trying to revoke them.

The source

The whole diagram is this plain text. Copy it into your repo, or open it in the editor.

jwt-refresh-token.volley
title: JWT access token with a rotating refresh token
autonumber
order: User, Browser, Auth Server, Token Store, API

User -> Browser: Sign in with email and password
Browser -> Auth Server: POST /login
in: Credentials
```json
{ "email": "ada@example.com", "password": "<redacted>" }
```

Auth Server -> Token Store: Start a refresh token family
in: Row
  family_id: fam_7Qx2
  token_hash: 3f9a1c0e8b7d
  user_id: usr_42
  expires_at: 2026-11-18T14:00:00Z

Auth Server --> Browser: 200 OK
out: Tokens
```http
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Set-Cookie: refresh_token=<redacted>; HttpOnly; Secure; SameSite=Strict; Path=/token/refresh; Max-Age=2592000

{ "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0.eyJzdWIiOiJ1c3JfNDIiLCJleHAiOjE3NjA4ODMxMDB9.c2lnbmF0dXJl", "token_type": "Bearer", "expires_in": 900 }
```
note: Keep the access token in memory, not localStorage. The refresh token sits in a cookie scripts cannot read, sent only to `/token/refresh`.

=: {fas-key} Calling the API

Browser -> API: GET /api/orders
in: Headers
  Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0.eyJzdWIiOiJ1c3JfNDIiLCJleHAiOjE3NjA4ODMxMDB9.c2lnbmF0dXJl

API -> API: Check the signature, exp, iss and aud
note: The API verifies the JWT with the auth server's public key from its JWKS. No call to the auth server.

API --> Browser: 200 OK
out: Orders
```json
{ "orders": [{ "id": "ord_42", "total": 4900, "status": "shipped" }] }
```

...: {fas-clock} 15 minutes later the access token expires

Browser -> API: GET /api/orders
in: Headers
  Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0.eyJzdWIiOiJ1c3JfNDIiLCJleHAiOjE3NjA4ODMxMDB9.c2lnbmF0dXJl

API --> Browser: 401 Unauthorized
out: Response headers
  WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"

=: {fas-sync-alt} Refreshing

Browser -> Auth Server: POST /token/refresh
in: Request
```http
POST /token/refresh HTTP/1.1
Cookie: refresh_token=<redacted>
```

Auth Server -> Token Store: Look up the token by its hash
Token Store --> Auth Server: Token row
out: Row
  family_id: fam_7Qx2
  used: false
  expires_at: 2026-11-18T14:00:00Z

if: Token is current
  Auth Server -> Token Store: Mark it used and save its replacement
  Auth Server --> Browser: 200 OK
  out: New tokens
  ```http
  HTTP/1.1 200 OK
  Content-Type: application/json
  Cache-Control: no-store
  Set-Cookie: refresh_token=<redacted>; HttpOnly; Secure; SameSite=Strict; Path=/token/refresh; Max-Age=2592000

  { "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0.eyJzdWIiOiJ1c3JfNDIiLCJleHAiOjE3NjA4ODQwMDB9.bmV3c2ln", "token_type": "Bearer", "expires_in": 900 }
  ```
  Browser -> API: Retry GET /api/orders
  in: Headers
    Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0.eyJzdWIiOiJ1c3JfNDIiLCJleHAiOjE3NjA4ODQwMDB9.bmV3c2ln
  API --> Browser: 200 OK
else: Token was already used
  Auth Server -> Token Store: Revoke every token in fam_7Qx2
  Auth Server --> Browser: 401 Unauthorized
  out: Error
  ```http
  HTTP/1.1 401 Unauthorized
  Content-Type: application/json
  Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/token/refresh; Max-Age=0

  { "error": "invalid_grant", "error_description": "Refresh token was already used" }
  ```
  Browser -> User: Ask the user to sign in again
end
note Browser, Token Store: A used refresh token showing up again means someone else has a copy. Revoking the whole family signs out both the thief and the real user.

Related

Make this JWT refresh token rotation diagram yours.

Open it in the editor, change it to match your integration and share the link. Free, with no account.