Complete guide to automatic ID token and access token refresh in axum-oidc-client.
The axum-oidc-client library provides automatic ID token and access token refresh functionality that transparently handles expired tokens without requiring any manual intervention in your application code. When you use the library's extractors, ID tokens and access tokens are automatically refreshed using the OAuth2 refresh token flow whenever they expire.
When a request arrives at a protected route:
- Extraction - The extractor retrieves the session from cache using the session cookie
- Expiration Check - Inspects
session.expires:- If
None(no expiry info was available at session creation) → refresh is skipped entirely - If
Some(t)andt > now→ token is still valid, no refresh needed - If
Some(t)andt <= now→ token has expired, proceed to refresh
- If
- Refresh Token Check - Inspects
session.refresh_token:- If
None→ cannot refresh; user is redirected to re-authenticate - If
Some(token)→ proceed with the refresh request
- If
- Conditional Refresh - If expired and refresh token present:
- Sends POST request to the OAuth2 token endpoint
- Includes the refresh token and client credentials
- Receives new ID token, access token, and optionally a new expiration time from the provider
- Updates
access_token,id_token, andexpires(only if new expiry info is returned) - Updates
refresh_tokenif the provider issues a new one (token rotation) - Saves the updated session back to cache
- Handler Execution - Your route handler receives the fresh, valid tokens
┌─────────────┐
│ Request │
└──────┬──────┘
│
▼
┌─────────────────────┐
│ Extract Session ID │
│ from Cookie Jar │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ Retrieve Session │
│ from Cache │
└──────┬──────────────┘
│
▼
┌──────────────────────────┐
│ Check expires field │
│ (is Some and <= now?) │
└──────┬───────────────────┘
│
├─ None / Not expired ──────┐
│ │
│ Some(t) and t <= now ▼
▼ ┌──────────────┐
┌─────────────────────┐ │ Return Fresh │
│ Check refresh_token │ │ Session │
│ (is Some?) │ └──────┬───────┘
└──────┬──────────────┘ │
│ │
├─ None ────────────────────┤
│ (redirect to re-auth) │
│ Some(token) │
▼ │
┌─────────────────────┐ │
│ POST /token │ │
│ grant_type=refresh │ │
│ refresh_token=... │ │
└──────┬──────────────┘ │
│ │
▼ │
┌─────────────────────┐ │
│ Update Session │ │
│ - access_token │ │
│ - id_token │ │
│ - expires (if new │ │
│ expiry returned) │ │
└──────┬──────────────┘ │
│ │
▼ │
┌─────────────────────┐ │
│ Save to Cache │ │
└──────┬──────────────┘ │
│ │
└───────────────────────────┘
│
▼
┌──────────────┐
│ Handler │
│ (receives │
│ fresh token) │
└──────────────┘
All of the following extractors support automatic ID token and access token refresh:
These extractors require authentication and will redirect to OAuth if the user is not logged in:
AuthSession- Full session with all token information (ID token and access token automatically refreshed if expired)AccessToken- Just the access token (automatically refreshed if expired)IdToken- Just the ID token (automatically refreshed if expired)
These extractors work for both authenticated and unauthenticated users:
OptionalAuthSession- Optional full session (ID token and access token automatically refreshed if expired when present)OptionalAccessToken- Optional access token (automatically refreshed if expired when present)OptionalIdToken- Optional ID token (automatically refreshed if expired when present)
use axum_oidc_client::auth_session::AuthSession;
async fn dashboard(session: AuthSession) -> String {
// If ID token and access token were expired, they have already been refreshed
// You always receive valid, fresh tokens
let expires = session.expires
.map(|e| e.to_string())
.unwrap_or_else(|| "(no expiry)".to_string());
let scope = session.scope.as_deref().unwrap_or("(none)");
format!(
"Dashboard\n\
Token Type: {}\n\
Expires: {}\n\
Scopes: {}",
session.token_type,
expires,
scope
)
}use axum_oidc_client::extractors::AccessToken;
async fn api_call(token: AccessToken) -> String {
// Access token is automatically refreshed if it was expired
// Safe to use for external API calls
format!("Making API call with token: {}", &*token[..20])
}use axum_oidc_client::extractors::AccessToken;
use reqwest::Client;
async fn fetch_user_data(token: AccessToken) -> Result<String, String> {
let client = Client::new();
// Access token is guaranteed to be fresh and valid (auto-refreshed if expired)
let response = client
.get("https://api.example.com/user/profile")
.bearer_auth(&*token)
.send()
.await
.map_err(|e| e.to_string())?;
response.text().await.map_err(|e| e.to_string())
}use axum_oidc_client::extractors::OptionalAccessToken;
async fn personalized_content(
OptionalAccessToken(token): OptionalAccessToken
) -> String {
match token {
Some(access_token) => {
// Access token is automatically refreshed if it was expired
format!("Personalized content for authenticated user")
}
None => {
format!("Public content")
}
}
}To enable ID token and access token refresh, you must request the appropriate scope from your OAuth2 provider:
use axum_oidc_client::auth_builder::OAuthConfigurationBuilder;
let config = OAuthConfigurationBuilder::default()
// ... other config ...
.with_scopes(vec![
"openid",
"email",
"profile",
"offline_access" // Required for refresh tokens on most providers
])
.build()?;Most OAuth2 Providers (Keycloak, Auth0, Azure AD, etc.):
.with_scopes(vec!["openid", "email", "offline_access"])Google:
// Google uses "openid" and returns refresh tokens automatically
// when access_type=offline is set (handled by the library)
.with_scopes(vec!["openid", "email", "profile"])GitHub:
// GitHub doesn't expire tokens by default, but supports refresh
.with_scopes(vec!["read:user", "user:email"])Ensure your OAuth2 provider supports refresh tokens. Check the provider's documentation:
- Does it support the
refresh_tokengrant type? - Does it return a
refresh_tokenin the token response? - What is the refresh token lifetime?
Set appropriate expiration times in your configuration:
let config = OAuthConfigurationBuilder::default()
// ... other config ...
.with_session_max_age(30) // Session valid for 30 minutes
.with_token_max_age(300) // Force token refresh after 5 minutes
.build()?;When ID token and access token are refreshed, the provider returns a response like:
{
"access_token": "new_access_token_here",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "new_refresh_token_here",
"id_token": "new_id_token_here",
"scope": "openid email profile"
}The library automatically updates the session with:
access_token- Always updated with the new access tokenid_token- Updated with new ID token if provider returns it (optional)refresh_token- Updated if provider returns a new refresh token (optional)expires- Calculated fromexpires_inandtoken_max_agescope- Updated if provider returns it (optional)
If ID token and access token refresh fails (e.g., refresh token expired or revoked), the extractor:
- Returns Error - The extractor fails with an authentication error
- Redirects User - User is automatically redirected to the OAuth2 provider
- Prevents Handler Execution - Your route handler is never called with invalid/expired tokens
async fn protected(session: AuthSession) -> String {
// If refresh failed, this handler is never executed
// User is redirected to re-authenticate instead
format!("Tokens are valid: {} / {}", session.access_token, session.id_token)
}| Error Condition | Cause | Solution |
|---|---|---|
invalid_grant |
Refresh token expired | User must re-authenticate |
invalid_grant |
Refresh token revoked | User must re-authenticate |
invalid_client |
Client credentials wrong | Check configuration |
| Network timeout | Provider unreachable | Check network/provider status |
| Cache error | Can't save updated session | Check cache connection |
Choose the right extractor for your use case:
// Need full session info? Use AuthSession
async fn dashboard(session: AuthSession) -> String { /* ... */ }
// Only need access token for API calls? Use AccessToken (more efficient)
async fn api_call(token: AccessToken) -> String { /* ... */ }
// Optional authentication? Use Optional variants
async fn home(OptionalIdToken(token): OptionalIdToken) -> String { /* ... */ }Balance security and user experience:
.with_session_max_age(30) // 30 minutes - good balance
.with_token_max_age(300) // 5 minutes - force frequent refreshShort lifetimes (< 5 min):
- ✅ Better security
- ❌ More refresh requests
- ❌ Higher load on provider
Long lifetimes (> 60 min):
- ✅ Fewer refresh requests
- ✅ Better performance
- ❌ Longer exposure if token leaked
Log refresh failures to detect issues:
// In production, log failed refreshes
// This helps identify when users need to re-authenticateIf the token endpoint is unavailable:
- Users with valid tokens continue working
- Users with expired tokens must wait for provider recovery
- Consider implementing retry logic for transient failures
Ensure your cache can handle concurrent updates:
use axum_oidc_client::cache::{TwoTierAuthCache, config::TwoTierCacheConfig};
// L1-only in-memory cache (requires `moka-cache` feature, enabled by default)
let cache = Arc::new(
TwoTierAuthCache::new(None, TwoTierCacheConfig::default())
.expect("failed to build cache")
);
// Two-tier: Moka L1 + Redis L2 (requires both `moka-cache` and `redis` features)
let redis = Arc::new(axum_oidc_client::redis::AuthCache::new("redis://127.0.0.1/", 3600));
let cache = Arc::new(
TwoTierAuthCache::new(Some(redis), TwoTierCacheConfig::default())
.expect("failed to build cache")
);use tracing_subscriber;
tracing_subscriber::fmt::init();This will log refresh attempts and failures.
async fn debug_session(session: AuthSession) -> String {
let now = chrono::Local::now();
let expires = session.expires
.map(|e| e.to_string())
.unwrap_or_else(|| "(no expiry)".to_string());
let is_expired = session.expires
.map(|e| e <= now)
.unwrap_or(false);
format!(
"Session Debug:\n\
Current Time: {}\n\
Expires: {}\n\
Is Expired: {}\n\
Has Refresh Token: {}",
now,
expires,
is_expired,
session.refresh_token.is_some()
)
}// 1. Authenticate and get initial tokens
// 2. Wait for tokens to expire (or manually set short lifetime)
// 3. Make another request
// 4. Verify new ID token and access token were issued
async fn test_refresh(session: AuthSession) -> String {
// Check expiration and tokens
// If you see a newer expiration time than initial auth,
// ID token and access token refresh is working
let expires = session.expires
.map(|e| e.to_string())
.unwrap_or_else(|| "(no expiry)".to_string());
format!("Expires: {} | Token: {}",
expires,
&session.access_token[..20.min(session.access_token.len())]
)
}- ✅ Refreshed ID tokens and access tokens in sessions are immediately saved to cache
- ✅ Subsequent requests use the cached refreshed tokens
- ✅ No redundant refresh requests for the same session
Each ID token and access token refresh requires:
- POST request to token endpoint with refresh token (~100-500ms)
- Cache update operation to save new tokens (~1-10ms)
- Use token_max_age wisely - Don't force ID token and access token refresh too frequently
- Use specific extractors -
AccessTokenorIdTokenare lighter than fullAuthSession - Monitor refresh frequency - High frequency may indicate misconfiguration
- ✅ Refresh tokens stored in server-side cache only (used to obtain new ID tokens and access tokens)
- ✅ Never exposed to client (browser)
- ✅ Session ID encrypted in cookie, not the tokens themselves
- ✅ Refresh tokens deleted when user logs out
Some providers issue new refresh tokens on each ID token and access token refresh:
- ✅ Library automatically updates to new refresh token when provided
- ✅ Old refresh token is discarded
- ✅ Provides additional security through refresh token rotation
If provider reduces granted scopes during refresh:
- ✅ Session is updated with new scope list
⚠️ Your application should verify required scopes are present
Check:
- Is
offline_access(or equivalent) scope requested? - Does provider support refresh tokens for obtaining new ID tokens and access tokens?
- Is refresh token present in session?
- Are there errors in logs?
Solution:
# Check provider returns refresh token
curl -X POST https://provider.com/token \
-d grant_type=authorization_code \
-d code=AUTH_CODE \
-d client_id=CLIENT_ID \
-d client_secret=CLIENT_SECRET
# Look for "refresh_token" in responsePossible Causes:
- Refresh token expired (check provider's refresh token lifetime)
- Refresh token revoked by user or admin
- Client credentials changed since token was issued
Solution:
- User must re-authenticate
- Check provider's refresh token settings
Possible Causes:
token_max_ageset too low, forcing frequent ID token and access token refresh- Provider's access token lifetime too short
- Multiple concurrent requests causing repeated refresh attempts
Solution:
// Increase token_max_age
.with_token_max_age(600) // 10 minutes instead of 5Possible Causes:
- Cache connection lost during save of refreshed ID token and access token
- Serialization error when saving updated session
- Cache key mismatch
Solution:
- Check cache connectivity
- Verify cache configuration
- Check logs for specific errors
If you need custom refresh behavior, implement your own cache:
use axum_oidc_client::auth_cache::AuthCache;
use async_trait::async_trait;
struct CustomCache;
#[async_trait]
impl AuthCache for CustomCache {
async fn get(&self, key: &str) -> Option<String> {
// Custom get logic with refresh handling
}
async fn set(&self, key: &str, value: &str) {
// Custom set logic
}
async fn delete(&self, key: &str) {
// Custom delete logic
}
}Track ID token and access token refresh operations:
// Implement custom metrics in your cache implementation
// Track:
// - Number of ID token and access token refreshes per hour
// - Refresh success/failure rate
// - Average refresh latencyThe automatic ID token and access token refresh feature in axum-oidc-client:
- ✅ Works transparently without code changes
- ✅ Handles expired ID tokens and access tokens automatically
- ✅ Updates sessions with refreshed tokens atomically in cache
- ✅ Supports all OAuth2 providers that support refresh tokens
- ✅ Provides robust error handling
- ✅ Optimized for performance and security
Your application code never needs to manually check expiration or refresh ID tokens and access tokens - the library handles it all automatically when you use the provided extractors.