Table of Contents
- Introduction
- OAuth 2.0 Overview
- Why Access Tokens Expire
- Refresh Token Basics
- Grant Types that Issue Refresh Tokens
- Security Considerations
- Token Lifecycle Diagram
- Implementing Refresh Tokens in Popular Stacks
- Revocation and Rotation Strategies
- Common Pitfalls & Debugging Tips
- Testing the Refresh Flow
12 Best‑Practice Checklist - Conclusion
- Resources
Introduction
In modern web and mobile ecosystems, OAuth 2.0 has become the de‑facto standard for delegated authorization. While the access token is the workhorse that grants a client permission to act on behalf of a user, the refresh token is the unsung hero that enables long‑running sessions without repeatedly prompting the user for credentials.
This article dives deep into the why, how, and best practices surrounding OAuth refresh tokens. We’ll start with a brief refresher on OAuth 2.0, then explore the security model behind token expiration, present concrete code samples in three major programming ecosystems, and finish with a checklist you can use to audit any production implementation.
Whether you’re building a public API, an internal micro‑service architecture, or a consumer‑facing mobile app, mastering refresh tokens is essential for delivering a seamless yet secure user experience.
OAuth 2.0 Overview
OAuth 2.0 is a framework that defines four roles and five grant types:
| Role | Description |
|---|---|
| Resource Owner | End‑user who authorizes access to their resources. |
| Client | Application (web, mobile, server) that requests access. |
| Authorization Server | Issues tokens after authenticating the resource owner. |
| Resource Server | Hosts protected resources; validates access tokens. |
The grant types (Authorization Code, Implicit, Resource Owner Password Credentials, Client Credentials, and Refresh Token) describe how a client obtains an access token. The refresh token grant is a special flow that lets a client exchange a long‑lived token for a fresh access token when the original expires.
A typical OAuth flow looks like this:
- User logs in at the Authorization Server.
- Authorization Code is returned to the client.
- Client exchanges the code for an access token and an optional refresh token.
- Client uses the access token to call the Resource Server.
- When the access token expires, client sends the refresh token to the Authorization Server to obtain a new access token (and possibly a new refresh token).
Understanding each step is crucial because the refresh token’s security properties depend on how the Authorization Server treats it throughout the lifecycle.
Why Access Tokens Expire
1. Limiting Attack Surface
If an attacker steals an access token, its limited lifespan reduces the window of exploitation. Short‑lived tokens (minutes to a few hours) are a common mitigation strategy.
2. Revocation Flexibility
Resource servers can enforce revocation by refusing tokens that have been black‑listed. Short lifetimes mean revocation can be enforced more promptly.
3. Scope & Consent Changes
User consent may evolve. By forcing periodic re‑issuance, the Authorization Server can re‑evaluate scopes and ask the user for additional permissions if needed.
4. Compliance Requirements
Regulations (e.g., GDPR, HIPAA) often mandate that credentials and tokens be rotated regularly. Short‑lived access tokens help meet such mandates.
Because of these reasons, access tokens are intentionally short‑lived, while refresh tokens are long‑lived—but they are stored and handled with a higher security bar.
Refresh Token Basics
A refresh token is a credential that the client can use to obtain a new access token without further user interaction. Key attributes:
| Attribute | Explanation |
|---|---|
| Longevity | Usually valid for weeks, months, or indefinitely until revoked. |
| Confidentiality | Must be stored securely (e.g., encrypted at rest, HttpOnly cookies, secure mobile keychains). |
| Scope | Typically inherits the scopes of the original access token, but servers may narrow them on refresh. |
| Rotation | Many modern implementations rotate the refresh token on each use (i.e., issue a new refresh token and invalidate the old one). |
| Binding | Some servers bind the token to a client identifier, IP address, or device fingerprint to mitigate token theft. |
Refresh Token Request
The client sends a POST request to the token endpoint:
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=Rk9Y...b3Jk&
client_id=client123&
client_secret=secretXYZ
A successful response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "newRefreshTokenIfRotated",
"scope": "read write"
}
If the refresh token is invalid, expired, or revoked, the server returns a 400 with error=invalid_grant.
Grant Types that Issue Refresh Tokens
Not every OAuth flow yields a refresh token. The most common are:
| Grant Type | Typical Use‑Case | Refresh Token Issued? |
|---|---|---|
| Authorization Code (confidential clients) | Web apps, server‑side apps | ✅ |
| Authorization Code (PKCE) | Mobile & SPA apps | ✅ (if the server allows) |
| Resource Owner Password Credentials | Legacy internal apps | ✅ (but discouraged) |
| Client Credentials | Service‑to‑service | ❌ (no resource owner) |
| Implicit | Browser‑only apps | ❌ (deprecated for security) |
PKCE (Proof Key for Code Exchange) is now recommended for public clients (mobile, SPA) because it mitigates authorization code interception attacks while still allowing a refresh token to be issued.
Security Considerations
1. Storage
| Platform | Recommended Storage |
|---|---|
| Web (SPA) | HttpOnly, Secure SameSite=Strict cookie (or IndexedDB with encryption). |
| Mobile (iOS/Android) | OS keychain / Keystore (e.g., Keychain on iOS, EncryptedSharedPreferences on Android). |
| Server‑side | Encrypted database column or secret manager (AWS Secrets Manager, HashiCorp Vault). |
Never store refresh tokens in local storage or plain‑text files.
2. Transmission
- TLS only: All token requests must be over HTTPS.
- Audience restriction: Ensure the token endpoint validates the
client_idand, when applicable, theclient_secretorclient_assertion.
3. Rotation & Revocation
- Rotate on each use: Issue a new refresh token and invalidate the old one. This limits the impact of a stolen token.
- Revocation endpoint: Provide a
POST /oauth/revokeendpoint per RFC 7009 to allow clients or users to invalidate tokens.
4. Scope Limitation
Avoid issuing overly broad scopes to a refresh token. If a client only needs read:profile, do not grant write:profile just because the original access token had it.
5. Detect Anomalies
- Reuse detection: If a refresh token is used more than once, treat it as a possible theft and revoke it.
- IP/device fingerprint checks: Compare request metadata against the original issuance context.
6. Compliance with RFC 6749 & RFC 6819
- Follow the OAuth 2.0 Threat Model and Security Considerations (RFC 6819) for best‑practice mitigations.
- Implement Proof Key for Code Exchange (PKCE) for public clients as per RFC 7636.
Token Lifecycle Diagram
Below is a simplified diagram of the refresh token lifecycle:
+----------------------+ +-------------------+
| Resource Owner | | Authorization Srv |
+----------+-----------+ +---------+---------+
| |
(1) Auth Request |
| |
v |
+-------------------+ |
| Authorization Code|<---(2)-----+
+-------------------+ |
| |
(3) Token Request (code+secret) |
| |
v |
+-------------------+ |
| Access + Refresh |---(4)----->|
| Token Response | |
+-------------------+ |
| |
(5) Access Resource |
| |
v |
+-------------------+ |
| Resource Server | |
+-------------------+ |
| |
(6) Token Expired |
| |
v |
+-------------------+ |
| Refresh Request |---(7)----->|
| (refresh token) | |
+-------------------+ |
| |
(8) New Access (+new refresh) |
| |
v |
+-------------------+ |
| Access Resource |<----------+
+-------------------+
Key points:
- Steps (6)–(8) repeat until the refresh token is revoked or expires.
- If rotation is enabled, step (8) returns a new refresh token.
Implementing Refresh Tokens in Popular Stacks
Below we present minimal, production‑ready examples for three ecosystems. All examples assume an existing OAuth 2.0 Authorization Server that complies with RFC 6749.
8.1 Node.js / Express
Dependencies
npm install express axios body-parser cookie-parser dotenv
Server Code (app.js)
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
// Configuration (env variables)
const AUTH_SERVER_TOKEN_URL = process.env.AUTH_SERVER_TOKEN_URL; // e.g., https://auth.example.com/oauth/token
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;
// Helper to request a new access token using a refresh token
async function refreshAccessToken(refreshToken) {
const params = new URLSearchParams();
params.append('grant_type', 'refresh_token');
params.append('refresh_token', refreshToken);
params.append('client_id', CLIENT_ID);
params.append('client_secret', CLIENT_SECRET);
const response = await axios.post(AUTH_SERVER_TOKEN_URL, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return response.data; // { access_token, refresh_token?, expires_in, ... }
}
// Middleware to protect routes
async function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
let accessToken = authHeader && authHeader.split(' ')[1];
const storedRefresh = req.cookies.refresh_token;
// If no access token, try to refresh
if (!accessToken && storedRefresh) {
try {
const tokenSet = await refreshAccessToken(storedRefresh);
accessToken = tokenSet.access_token;
// Rotate refresh token if present
if (tokenSet.refresh_token) {
res.cookie('refresh_token', tokenSet.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
}
} catch (err) {
console.error('Refresh failed:', err.response?.data);
return res.status(401).json({ error: 'Invalid session' });
}
}
if (!accessToken) {
return res.status(401).json({ error: 'Missing access token' });
}
// Attach token to request for downstream handlers
req.accessToken = accessToken;
next();
}
// Example protected endpoint
app.get('/profile', authMiddleware, async (req, res) => {
try {
const apiRes = await axios.get('https://api.example.com/me', {
headers: { Authorization: `Bearer ${req.accessToken}` },
});
res.json(apiRes.data);
} catch (e) {
if (e.response?.status === 401) {
// Token may have expired; force client to re‑login
return res.status(401).json({ error: 'Access token expired' });
}
res.status(500).json({ error: 'Upstream error' });
}
});
app.listen(3000, () => console.log('App listening on :3000'));
Key takeaways
- Refresh token stored in an HttpOnly, Secure cookie.
- On each request, if the access token is missing, the middleware tries a refresh.
- Rotation is automatically handled: a new refresh token replaces the old cookie.
8.2 Python / FastAPI
Dependencies
pip install fastapi uvicorn httpx python-dotenv
FastAPI Application (main.py)
import os
from fastapi import FastAPI, Request, Response, HTTPException, Depends, Cookie
from fastapi.responses import JSONResponse
import httpx
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
AUTH_TOKEN_URL = os.getenv("AUTH_TOKEN_URL") # e.g., https://auth.example.com/oauth/token
CLIENT_ID = os.getenv("CLIENT_ID")
CLIENT_SECRET = os.getenv("CLIENT_SECRET")
async def refresh_access_token(refresh_token: str) -> dict:
async with httpx.AsyncClient() as client:
payload = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
resp = await client.post(AUTH_TOKEN_URL, data=payload, headers=headers)
resp.raise_for_status()
return resp.json()
async def get_valid_access_token(
request: Request,
response: Response,
refresh_token: str = Cookie(None),
):
auth = request.headers.get("Authorization")
token = auth.split(" ")[1] if auth else None
if token:
return token
if not refresh_token:
raise HTTPException(status_code=401, detail="Unauthenticated")
try:
token_set = await refresh_access_token(refresh_token)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=401, detail="Refresh token invalid") from exc
# Set new refresh token if rotated
new_rt = token_set.get("refresh_token")
if new_rt:
response.set_cookie(
key="refresh_token",
value=new_rt,
httponly=True,
secure=True,
samesite="strict",
max_age=30 * 24 * 60 * 60, # 30 days
)
return token_set["access_token"]
@app.get("/me")
async def me(
request: Request,
response: Response,
access_token: str = Depends(get_valid_access_token),
):
async with httpx.AsyncClient() as client:
api_resp = await client.get(
"https://api.example.com/me",
headers={"Authorization": f"Bearer {access_token}"},
)
if api_resp.status_code == 401:
raise HTTPException(status_code=401, detail="Access token expired")
api_resp.raise_for_status()
return JSONResponse(content=api_resp.json())
Highlights
- Dependency injection (
Depends) supplies a valid access token, refreshing automatically if needed. - The refresh token lives in an HttpOnly cookie; FastAPI automatically parses it via the
Cookieparameter. - Rotation is reflected by overwriting the cookie with the new token.
8.3 Java / Spring Security
Maven Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
</dependencies>
Configuration (application.yml)
spring:
security:
oauth2:
client:
registration:
my-client:
client-id: ${CLIENT_ID}
client-secret: ${CLIENT_SECRET}
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: read,write
provider:
my-provider:
token-uri: https://auth.example.com/oauth/token
authorization-uri: https://auth.example.com/oauth/authorize
user-info-uri: https://auth.example.com/userinfo
Security Filter for Refresh
@Component
public class RefreshTokenFilter extends OncePerRequestFilter {
@Value("${spring.security.oauth2.client.provider.my-provider.token-uri}")
private String tokenUri;
@Autowired
private ClientRegistrationRepository clientRegistrationRepository;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof OAuth2AuthenticationToken oauthToken) {
OAuth2AuthorizedClient authorizedClient = getAuthorizedClient(oauthToken);
if (authorizedClient != null && authorizedClient.getAccessToken().isExpired()) {
OAuth2AuthorizedClient refreshed = refresh(authorizedClient, request);
// Update security context with refreshed client
SecurityContextHolder.getContext().setAuthentication(
new OAuth2AuthenticationToken(oauthToken.getPrincipal(),
oauthToken.getAuthorities(),
oauthToken.getAuthorizedClientRegistrationId()));
// Optionally store new refresh token in HttpOnly cookie
writeRefreshCookie(response, refreshed.getRefreshToken());
}
}
filterChain.doFilter(request, response);
}
private OAuth2AuthorizedClient getAuthorizedClient(OAuth2AuthenticationToken token) {
OAuth2AuthorizedClientService clientService = new InMemoryOAuth2AuthorizedClientService(
clientRegistrationRepository);
return clientService.loadAuthorizedClient(
token.getAuthorizedClientRegistrationId(),
token.getName());
}
private OAuth2AuthorizedClient refresh(OAuth2AuthorizedClient client,
HttpServletRequest request) {
OAuth2RefreshTokenGrantRequest refreshRequest = new OAuth2RefreshTokenGrantRequest(
client.getClientRegistration(),
client.getAccessToken(),
client.getRefreshToken());
OAuth2AccessTokenResponse response = new DefaultRefreshTokenTokenResponseClient()
.getTokenResponse(refreshRequest);
return new OAuth2AuthorizedClient(
client.getClientRegistration(),
client.getPrincipalName(),
response.getAccessToken(),
response.getRefreshToken());
}
private void writeRefreshCookie(HttpServletResponse response,
OAuth2RefreshToken refreshToken) {
if (refreshToken == null) return;
Cookie cookie = new Cookie("refresh_token", refreshToken.getTokenValue());
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/");
cookie.setMaxAge((int) Duration.ofDays(30).getSeconds());
response.addCookie(cookie);
}
}
Explanation
- The filter intercepts every request, checks token expiration, and triggers a refresh token grant using Spring’s
DefaultRefreshTokenTokenResponseClient. - On successful rotation, the new refresh token is written to a secure HttpOnly cookie.
- The example uses an in‑memory authorized‑client service for brevity; production systems should persist authorized clients (e.g., JDBC, Redis).
Revocation and Rotation Strategies
1. Token Revocation Endpoint (RFC 7009)
Provide a POST endpoint:
POST /oauth/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
token=Rk9Y...b3Jk&token_type_hint=refresh_token
The Authorization Server must:
- Validate the client.
- Invalidate the token (remove from DB, add to blacklist).
- Return
200 OKeven if the token was already invalid (idempotent).
2. Refresh Token Rotation
Why rotate? If a refresh token is intercepted, rotation guarantees that the attacker’s token becomes useless after the legitimate client performs a refresh.
Implementation steps
- When a refresh request succeeds, issue a new refresh token.
- Store the new token and delete the old one atomically.
- Return the new token in the response.
- If a refresh token is presented that has already been revoked, return
invalid_grant.
Detecting reuse
if tokenAlreadyUsed(refreshToken):
revokeAllTokensForUser(userId)
logSecurityEvent()
return error(invalid_grant)
3. Sliding vs Fixed Expiration
- Sliding expiration: Each successful refresh extends the refresh token’s TTL (e.g., 30 days from last use).
- Fixed expiration: Refresh token expires after a hard deadline regardless of activity.
Both have trade‑offs; sliding expiration improves usability but requires tighter monitoring for abuse.
4. Binding Tokens to Clients
For confidential clients, include the client_id and optionally client_secret in the refresh request. For public clients, use PKCE and consider mutual TLS (mTLS) binding.
Common Pitfalls & Debugging Tips
| Pitfall | Symptom | Fix |
|---|---|---|
| Storing refresh token in localStorage | Token leaks via XSS, leading to account takeover. | Move token to HttpOnly cookie or native secure storage. |
Missing grant_type=refresh_token | Authorization server returns unsupported_grant_type. | Ensure request body includes grant_type. |
| Using the same refresh token multiple times without rotation | Server returns invalid_grant after first use. | Enable rotation or configure server to allow reuse (not recommended). |
| Clock skew between client and server | invalid_grant because server thinks token is expired. | Use NTP on both sides; optionally allow a small clock tolerance. |
Forgetting to set Content-Type: application/x-www-form-urlencoded | Server returns invalid_request. | Set correct header or use a library that handles it. |
| Refresh token revocation not propagated to all services | Stale tokens still accepted by downstream APIs. | Centralize token validation (introspection endpoint) or share revocation list via cache. |
Debugging Checklist
- Inspect the HTTP request – confirm body, headers, and URL.
- Check server logs – look for
invalid_grantorunsupported_grant_type. - Validate token format – most refresh tokens are opaque strings; some providers use JWTs.
- Confirm client credentials – for confidential clients, a wrong secret leads to 401.
- Verify TLS – any non‑HTTPS request will be rejected by compliant servers.
Testing the Refresh Flow
Automated testing ensures the refresh logic works across edge cases.
1. Unit Test (Node.js with Jest)
// tokenService.test.js
const axios = require('axios');
jest.mock('axios');
const { refreshAccessToken } = require('./tokenService');
test('successful refresh returns new access token', async () => {
axios.post.mockResolvedValue({
data: {
access_token: 'newAccess123',
refresh_token: 'newRefresh456',
expires_in: 3600,
},
});
const result = await refreshAccessToken('oldRefreshToken');
expect(result.access_token).toBe('newAccess123');
expect(result.refresh_token).toBe('newRefresh456');
});
test('invalid refresh token throws', async () => {
axios.post.mockRejectedValue({
response: { status: 400, data: { error: 'invalid_grant' } },
});
await expect(refreshAccessToken('badToken')).rejects.toMatchObject({
response: { status: 400 },
});
});
2. Integration Test (FastAPI with Pytest)
import pytest
from httpx import AsyncClient
from main import app
@pytest.mark.asyncio
async def test_refresh_flow():
async with AsyncClient(app=app, base_url="http://test") as client:
# Simulate login that sets a refresh cookie
response = await client.post(
"/login",
json={"username": "alice", "password": "secret"},
)
assert response.status_code == 200
refresh_cookie = response.cookies.get("refresh_token")
assert refresh_cookie
# Force token expiry by clearing Authorization header
client.cookies.set("refresh_token", refresh_cookie)
resp = await client.get("/me")
assert resp.status_code == 200
data = resp.json()
assert "email" in data
3. End‑to‑End Test (Cypress for a SPA)
describe('OAuth Refresh Flow', () => {
it('should silently refresh token when API returns 401', () => {
cy.intercept('GET', '/api/profile', { statusCode: 401 }).as('profile401');
cy.intercept('POST', '/oauth/token', {
fixture: 'refresh-response.json',
}).as('refreshToken');
cy.visit('/');
cy.wait('@profile401');
cy.wait('@refreshToken').its('response.statusCode').should('eq', 200);
cy.get('#profile').should('contain', 'Welcome');
});
});
These tests cover unit, integration, and E2E perspectives, ensuring the refresh mechanism behaves as expected under normal and error conditions.
Best‑Practice Checklist
- Use HTTPS everywhere – token endpoints, redirects, and APIs.
- Store refresh tokens in a secure, HttpOnly container (cookies, OS keychain).
- Prefer Authorization Code + PKCE for public clients.
- Enable refresh token rotation on the Authorization Server.
- Implement a revocation endpoint and call it on logout or password change.
- Set reasonable lifetimes (e.g., access token ≤ 1 hour, refresh token ≤ 30 days).
- Validate
client_id/client_secreton each refresh request. - Detect token reuse and trigger revocation of all tokens for the user.
- Log security‑relevant events (refresh success/failure, reuse detection).
- Provide clear error messages (RFC‑compliant
erroranderror_description). - Test the flow with unit, integration, and end‑to‑end suites.
- Document the token lifecycle for developers and auditors.
Following this checklist dramatically reduces the attack surface while preserving a frictionless user experience.
Conclusion
Refresh tokens are the linchpin that makes OAuth 2.0 both secure and usable for long‑running applications. By understanding why access tokens expire, how refresh tokens are issued, and the myriad security considerations that surround them, you can design systems that keep user data safe without forcing users to re‑authenticate constantly.
We explored the full token lifecycle, walked through concrete implementations in Node.js, Python, and Java, and covered advanced topics such as rotation, revocation, and token binding. Finally, we equipped you with a practical checklist and testing strategies to verify that your implementation behaves correctly under real‑world conditions.
Implementing refresh tokens correctly is not optional—it’s a core part of any production‑grade OAuth deployment. Use the guidelines in this article as a foundation, adapt them to your threat model, and keep iterating as standards evolve.
Resources
OAuth 2.0 Authorization Framework (RFC 6749) – The official specification.
RFC 6749OAuth 2.0 Threat Model and Security Considerations (RFC 6819) – Detailed security guidance.
RFC 6819Proof Key for Code Exchange (PKCE) – RFC 7636 – Essential for public clients.
RFC 7636OAuth 2.0 Token Revocation (RFC 7009) – Standard revocation endpoint.
RFC 7009Auth0 Blog: Refresh Token Rotation – Practical patterns and code snippets.
Refresh Token RotationOWASP OAuth Security Cheat Sheet – Consolidated best practices and pitfalls.
OWASP OAuth Cheat Sheet
Feel free to explore these resources to deepen your knowledge and stay aligned with the latest security recommendations. Happy