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.
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
- The user signs in. The auth server returns a JWT access token in the body and sets the refresh token as an
HttpOnly,Securecookie scoped to/token/refresh. - The browser calls the API with
Authorization: Bearer. The API checks the signature,exp,issandauditself, with no call to the auth server. - The access token expires and the API answers 401 with
WWW-Authenticate: Bearer error="invalid_token". - The browser posts to
/token/refreshand the cookie goes with it. The auth server marks that refresh token used, issues a new pair and sets the new cookie. - The browser retries the original call with the new access token.
- 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
expeven 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.