13.9. Refresh Tokens
Table of Contents
Why Refresh Tokens Exist
In many applications you want users to stay logged in for a long time, but you also want to reduce the risk if an attacker steals an access token.
An access token is usually:
- Short lived, for example 5 to 15 minutes
- Sent with every API request, often in the
Authorization: Bearer <token>header - Verified by the backend without storing session state, especially if you use JWTs
If you keep access tokens valid for hours or days:
- A stolen token gives an attacker a long window to act
- You have to revoke tokens when something bad happens, which is hard for stateless tokens
Refresh tokens solve this by splitting responsibilities:
- Access token: short lifetime, used for every API call
- Refresh token: long lifetime, used rarely, only to get new access tokens
So a user can stay logged in for days or weeks, but each individual access token is only valid for minutes.
Key idea: Use short lived access tokens for API calls and long lived refresh tokens only for refreshing access tokens, not for direct access to protected resources.
How Refresh Tokens Work
Basic Flow
A typical login and refresh flow looks like this:
- User logs in
- Client sends credentials to
/auth/login. - Server verifies them.
- Server returns:
- an access token (short expiry, for example 15 minutes)
- a refresh token (long expiry, for example 7 to 30 days)
- Client uses access token
- For each API request, client sends the access token in a header:
Authorization: Bearer <access_token>.- When the server receives the request, it validates the access token and processes the request.
- Access token expires
- After its expiry time, the server starts rejecting requests with that access token, usually with
401 Unauthorizedor403 Forbidden. - Client uses refresh token
- Before or after the access token expires, the client calls
/auth/refreshwith the refresh token. - If the refresh token is valid and not expired or revoked, the server issues:
- a new access token
- often a new refresh token too
- User logs out or token is revoked
- Client calls
/auth/logoutand sends the refresh token (and optionally access token). - Server marks the refresh token as revoked, or deletes it from storage.
- Client removes any tokens stored locally.
In formula-like form:
$$
\text{User credentials} \xrightarrow{\text{/auth/login}}
(\text{access\_token},\, \text{refresh\_token})
$$
Later:
$$
\text{refresh\_token} \xrightarrow{\text{/auth/refresh}}
(\text{new\_access\_token},\, \text{new\_refresh\_token})
$$
Example API Endpoints
A very common minimal set of endpoints:
| Endpoint | Method | Purpose |
|---|---|---|
/auth/login | POST | Get access + refresh tokens |
/auth/refresh | POST | Get new access (and refresh) |
/auth/logout | POST | Invalidate the refresh token |
Example JSON response from /auth/login:
{
"access_token": "eyJhbGciOiJIUzI1...",
"access_token_expires_in": 900,
"refresh_token": "a1b2c3d4e5f6...",
"refresh_token_expires_in": 2592000,
"token_type": "bearer"
}Time values are in seconds:
900seconds is 15 minutes2 592 000seconds is 30 days
Access Tokens vs Refresh Tokens
Although both are "tokens", they should be treated differently.
Comparison
| Feature | Access Token | Refresh Token |
|---|---|---|
| Purpose | Call protected APIs | Get new access tokens |
| Lifetime | Short (minutes) | Long (days or weeks) |
| Where used | Every API request | Only to refresh tokens |
| Risk if stolen | Moderate, limited by short expiry | High, can keep generating access tokens |
| Storage location | In memory, or short term storage | More protected storage, often HttpOnly cookie |
| Should be JWT? | Often yes | Can be JWT or opaque random string |
| Revocation | Hard if pure JWT, easier if stored | Must be revocable |
Never use refresh tokens directly to access protected data.
They must only be used to obtain new access tokens.
Opaque vs JWT Refresh Tokens
Two popular design choices:
- Opaque refresh tokens
- Random strings, for example
b7146e06-76a9-4e99-88de-.... - Server stores them in a database table:
id,user_id,token_hash,expires_at,revoked_at,user_agent,ip_address.- On
/auth/refresh, server: - looks up the token
- checks if still valid
- issues new tokens
- JWT refresh tokens
- Structured, signed tokens with claims like:
sub: user idexp: expirytype:"refresh"- Still often stored or tracked server side for revocation or rotation.
For beginners, opaque refresh tokens stored in a database are simpler and safer to reason about, because revocation is straightforward.
Token Rotation
Token rotation means: when the client uses a refresh token, the server:
- Returns a new refresh token
- Invalidates the old refresh token
So at any time, only the most recent refresh token is valid for that session.
Why Rotate?
Without rotation:
- A refresh token can be used many times until it expires.
- If an attacker steals it, both you and the attacker can keep using it until it expires.
- You have no way to detect that the token was copied.
With rotation:
- Every time the client refreshes, it gets a new token and the old one becomes invalid.
- If an attacker steals a refresh token, there will be two parties trying to use:
- the same old token, or
- different tokens at different times from different locations.
This makes it easier to detect suspicious behavior and to cut off stolen tokens early.
Rule: A refresh token should be single use. Once used to get new tokens, it must be invalidated and replaced.
Simple Rotation Workflow
Let us say the user has refresh_token_A.
- Client calls
/auth/refreshwithrefresh_token_A. - Server:
- Validates
refresh_token_A. - Marks
refresh_token_Aas used or revoked. - Creates
refresh_token_B. - Returns new access token and
refresh_token_B. - Client replaces its stored refresh token with
refresh_token_B.
Next time, the client must send refresh_token_B, not A.
Pseudo table for a token row:
| Column | Value |
|---|---|
| id | 123 |
| user_id | 42 |
| token_hash | hash of refresh_token_A |
| is_revoked | true |
| replaced_by_id | 124 |
New row:
| Column | Value |
|---|---|
| id | 124 |
| user_id | 42 |
| token_hash | hash of refresh_token_B |
| is_revoked | false |
| replaced_by_id | null |
Detecting Token Theft with Rotation
Scenario:
- Attacker steals
refresh_token_A. - User still has
Aand does not know it was stolen. - Both user and attacker try to refresh.
Different possibilities:
- Legitimate client uses
Afirst Ais exchanged forB, andAis revoked.- When attacker later uses
A, server sees: Aalready used or revoked.- This is a strong signal that
Awas stolen, so the server can: - revoke
Btoo - log the event
- ask the user to reauthenticate.
- Attacker uses
Afirst - Attacker gets
B. - When user later uses
A, server sees the same suspicious behavior.
In both cases, rotation helps detect that someone tried to use the same refresh token multiple times.
You can implement a simple rule:
If a refresh token is used after it was already rotated, treat it as a possible token theft and revoke the entire session.
Implementing Refresh Tokens in Practice
Typical Database Table
For opaque refresh tokens, you will often have a table like refresh_tokens:
| Column | Type | Description |
|---|---|---|
| id | UUID / integer | Internal token id |
| user_id | UUID / integer | The user this token belongs to |
| token_hash | string | Hash of the refresh token value |
| created_at | datetime | When token was created |
| expires_at | datetime | When token expires |
| revoked_at | datetime/null | When token was revoked, if any |
| replaced_by | id/null | New token id after rotation |
| user_agent | string/null | Optional, client information |
| ip_address | string/null | Optional, IP at creation |
Notice that you store a hash, not the raw token, similar to passwords.
Login Endpoint Example (Pseudocode)
Here is high level pseudocode, independent of any specific language or framework:
def login(username: str, password: str):
user = find_user_by_username(username)
if not user or not verify_password(password, user.password_hash):
raise UnauthorizedError("Invalid credentials")
access_token = create_access_token(user_id=user.id, expires_in=15 * 60)
refresh_token_value = generate_secure_random_string()
refresh_token_hash = hash_token(refresh_token_value)
save_refresh_token(
user_id=user.id,
token_hash=refresh_token_hash,
expires_in_days=30
)
return {
"access_token": access_token,
"access_token_expires_in": 15 * 60,
"refresh_token": refresh_token_value,
"refresh_token_expires_in": 30 * 24 * 60 * 60,
"token_type": "bearer"
}Refresh Endpoint with Rotation (Pseudocode)
def refresh(refresh_token_value: str):
refresh_token_hash = hash_token(refresh_token_value)
token = find_refresh_token_by_hash(refresh_token_hash)
if not token:
raise UnauthorizedError("Invalid refresh token")
if token.revoked_at is not None:
# Possible reuse of old token
revoke_token_family(token)
raise UnauthorizedError("Refresh token has been revoked")
if token.expires_at < now():
raise UnauthorizedError("Refresh token has expired")
user = find_user_by_id(token.user_id)
# Create new tokens
new_access_token = create_access_token(user_id=user.id, expires_in=15 * 60)
new_refresh_token_value = generate_secure_random_string()
new_refresh_token_hash = hash_token(new_refresh_token_value)
new_token = save_refresh_token(
user_id=user.id,
token_hash=new_refresh_token_hash,
expires_in_days=30
)
# Rotate: revoke the old refresh token and link it
token.revoked_at = now()
token.replaced_by = new_token.id
save(token)
return {
"access_token": new_access_token,
"access_token_expires_in": 15 * 60,
"refresh_token": new_refresh_token_value,
"refresh_token_expires_in": 30 * 24 * 60 * 60,
"token_type": "bearer"
}
The function revoke_token_family(token) could revoke all tokens in the same chain so that both the attacker and the user are forced to log in again.
Storing Refresh Tokens on the Client
How you store refresh tokens on the client side is critical for security.
Common Storage Options
| Client Type | Storage Place | Notes |
|---|---|---|
| Web browser | HttpOnly secure cookie | Protected from JavaScript, but watch CSRF |
| Single page app | HttpOnly cookie plus CSRF protection | Popular pattern |
| Mobile app | Secure storage (Keychain, Keystore) | Platform specific secure storage |
| Desktop app | Encrypted credentials store | For example OS keyring |
For web apps, a common approach:
- Store access token in memory (for example in a JavaScript variable or state).
- Store refresh token in an HttpOnly, Secure cookie.
- HttpOnly means JavaScript cannot read it.
- Secure means it is only sent over HTTPS.
Then, to refresh:
- Frontend calls
/auth/refreshwithcredentials: "include"so the cookie is sent. - Backend reads the only refresh token from the cookie.
- Backend returns a new access token.
Avoid storing refresh tokens in localStorage or sessionStorage, because JavaScript can access those and XSS vulnerabilities can steal them.
Security Best Practices for Refresh Tokens
Refresh tokens are powerful, so they need extra care.
1. Use HTTPS Everywhere
Always send refresh tokens only over HTTPS.
- Prevents attackers on the network from reading tokens.
- Set the
Secureflag on cookies so they are not sent over plain HTTP.
2. Limit Lifetime
Give refresh tokens a reasonable lifetime, for example:
- 7 days for sensitive apps
- 30 days for less sensitive consumer apps
Balance convenience with risk. A very long lived token is almost like a password.
3. Tie Tokens to a Device or Client
Store some additional metadata:
- User agent string
- Device id
- IP address at creation time (if appropriate)
Then you can:
- Show the user a list of "Active sessions" by device.
- Let the user revoke specific sessions.
Example sessions list:
| Device | Location | Last used | Action |
|---|---|---|---|
| Chrome on Windows | Berlin, Germany | 2026-08-27 09:14 | Log out |
| Safari on iPhone | Paris, France | 2026-08-26 21:03 | Log out |
Each line corresponds to a refresh token record or a group of them.
4. Invalidate on Logout
When user logs out, do not only delete tokens on the client side. Also on the server side:
- Mark the refresh token as revoked.
- Optionally, revoke all related tokens for that device or that user.
Logout pseudocode:
def logout(refresh_token_value: str):
refresh_token_hash = hash_token(refresh_token_value)
token = find_refresh_token_by_hash(refresh_token_hash)
if token:
token.revoked_at = now()
save(token)
return {"detail": "Logged out"}For cookie based tokens, you might:
- Clear the cookie on the client.
- Identify the token from the cookie in the backend and revoke it.
5. Do Not Mix Token Types
If you use JWTs for tokens, add a type claim.
Example JWT payload for an access token:
{
"sub": "42",
"type": "access",
"exp": 1693151220,
"scope": "read:tasks write:tasks"
}For a refresh token:
{
"sub": "42",
"type": "refresh",
"exp": 1695743220
}Then in your backend:
- Check that access tokens have
type="access", and only use them for protected routes. - Check that refresh tokens have
type="refresh", and only use them at/auth/refresh.
Never accept a refresh token in place of an access token to call normal API endpoints.
6. Use Strong Randomness
If tokens are opaque strings, generate them using a cryptographically secure random generator.
For example, in Python:
import secrets
def generate_secure_random_string(length: int = 64) -> str:
return secrets.token_urlsafe(length)This reduces the chance of guessing or brute forcing tokens.
7. Protect Against Brute Force
If an attacker tries many possible refresh tokens for a user:
- Limit refresh attempts per IP or per user.
- Log failed attempts.
- Consider blocking or slowing down requests after several failures.
Common Patterns and Scenarios
Sliding Sessions with Refresh Tokens
A sliding session extends the user's session as long as they keep using the app.
Example rule:
- Refresh tokens live 30 days.
- Every time the user refreshes, you issue a new refresh token that expires 30 days from now.
So the user can stay logged in as long as they are active, but if they are idle for more than 30 days, the refresh token expires and they must log in again.
You can implement a maximum session length by adding another rule:
- In addition to
expires_at, storemax_expires_atfor a refresh token family. - When refreshing, do not extend beyond
max_expires_at.
Remember Me Checkbox
Often there is a "Remember me" checkbox on the login page.
You can map it to different token lifetimes:
- If "Remember me" is unchecked:
- Refresh token lifetime is short, for example 1 day.
- If "Remember me" is checked:
- Refresh token lifetime is longer, for example 30 days.
Example:
def login(username: str, password: str, remember_me: bool):
# ... validate user ...
refresh_days = 30 if remember_me else 1
# create tokens with that lifetimeMultiple Devices
Users may log in on many devices:
- Phone
- Laptop
- Work computer
You can handle this by:
- Allowing multiple active refresh tokens per user, one per device.
- Each refresh token has its own id and metadata.
To log out from "all devices", you can:
- Revoke all refresh tokens for that user.
To log out from a single device, you can:
- Revoke only the specific token for that device, based on id or some identifier.
Example: End to End Scenario
Imagine a user "Alice" logging into a task management app.
- Login
- Alice enters username and password.
- Backend returns:
- Access token valid 15 minutes.
- Refresh token valid 14 days.
- Frontend:
- Keeps access token in memory.
- Stores refresh token in an HttpOnly cookie.
- Normal usage
- For the next 10 minutes, Alice adds tasks.
- Every request includes the access token.
- Backend processes requests, everyone is happy.
- Access token expires
- 15 minutes pass.
- Next request responds with
401 Unauthorizedbecause the access token is expired. - Frontend intercepts the 401 and triggers a silent refresh:
- Calls
/auth/refreshwith the cookie. - Backend:
- Reads the refresh token from the cookie.
- Validates and rotates it.
- Returns a new access token and a new refresh token.
- Frontend:
- Updates its in memory access token.
- Browser cookie is updated with the new refresh token.
- Frontend retries the original API request with the new access token, and it works.
- She stops using the app
- Alice closes the browser.
- After 2 days, she opens it again.
- Access token is gone from memory, but cookie still holds the refresh token.
- On first API call, app notices there is no access token, so it:
- Calls
/auth/refreshdirectly using the cookie. - Backend returns a new access token.
- Alice does not see a login screen. This feels like "remember me".
- Device lost
- Alice loses her phone.
- She logs into the app from her laptop and opens "Active sessions".
- Backend shows active refresh tokens with device info.
- She clicks "Log out" on the phone session.
- Backend revokes that phone's refresh token.
- Even if someone finds or steals the phone, they cannot refresh tokens anymore.
This scenario shows how refresh tokens create a session like experience on top of short lived access tokens.
Summary
Refresh tokens are a core building block for secure authentication:
- They let you keep access tokens short lived and safer.
- They give users a persistent session experience.
- With rotation, they help detect token theft and reduce damage from leaks.
- They require careful storage and revocation logic, but the pattern is straightforward.
In your backend, you will:
- Design endpoints like
/auth/login,/auth/refresh, and/auth/logout. - Decide where and how to store refresh tokens.
- Implement rotation and revocation.
- Apply security best practices around HTTPS, storage, and lifetime.
Once you understand refresh tokens, you can build robust, practical authentication flows for real world backend applications.
Views: 4
KAHIBARO