Access tokens expire by design -- usually within minutes. Without a refresh mechanism, every expiration forces the user to re-authenticate from scratch, which is unacceptable for long-running scripts, background services, and Python web applications that consume APIs. Authlib, the comprehensive Python OAuth library, provides both automatic and manual token refresh built into its OAuth2Session, along with callback hooks for persisting new tokens and detecting rotation violations. This guide covers each of these patterns with working code, verified against the Authlib Client API References and the relevant OAuth 2.0 RFCs.
Before walking through any code, ground yourself in this framing. A key grants permanent access -- copy it, and the copy works forever. A lease grants temporary access, expires on a schedule, and can be renewed or revoked by the landlord at any time. OAuth access tokens are leases. The authorization server is the landlord. Your application is the tenant. And the refresh token is the renewal clause in the contract.
Every section of this article maps to a phase of that landlord-tenant relationship: obtaining the lease (session setup), auto-renewal (automatic refresh), the renewal paperwork (the update_token callback), keeping a copy of the lease on file (persistence), the landlord changing the locks after each renewal (rotation), what happens when the lease is rejected (error handling), two tenants trying to renew simultaneously (race conditions), voluntarily ending the lease early (revocation), and choosing which property management company to use (Authlib vs alternatives).
Hold this model as you read. When you reach the section on race conditions and it feels abstract, come back here: two tenants mailing renewal letters on the same day, where the landlord only honors the first one and locks out anyone holding the old contract.
On March 2, 2026, Authlib released version 1.6.9 patching three security vulnerabilities: CVE-2026-27962 (CVSS 9.1 Critical -- JWK Header Injection allowing JWT forgery and complete authentication bypass), CVE-2026-28490 (padding oracle in JWE RSA1_5), and CVE-2026-28498 (ID Token validation bypass via unrecognized algorithm headers). All versions prior to 1.6.9 are affected. Run pip install --upgrade authlib immediately. Every code example in this article assumes 1.6.9 or later.
The OAuth 2.0 token lifecycle follows a predictable pattern defined in RFC 6749, Section 1.5: the client obtains an access token and a refresh token during the initial authorization. The access token is short-lived (typically 15 to 60 minutes) and used for API calls. When it expires, the client sends the refresh token to the authorization server's token endpoint and receives a fresh access token -- and in secure configurations, a new refresh token as well. Authlib handles this lifecycle at the session level, so your application code can focus on making API calls without managing token state manually.
Why Token Refresh Matters
Short-lived access tokens are a security feature, not a nuisance. If a token is stolen, the attacker has a narrow window to use it. But this protection creates a practical problem: an application that makes API calls over a span of hours or days needs a reliable way to obtain new tokens without user interaction. The refresh token solves this by acting as a long-lived credential that can be exchanged for a new access token at any time.
The catch is that refresh tokens themselves must be stored securely, rotated after each use, and revoked when suspicious activity is detected. As RFC 9700 (Best Current Practice for OAuth 2.0 Security, published January 2025) puts it, refresh tokens for public clients must be either sender-constrained or use refresh token rotation. For confidential clients, RFC 6749 already mandates that refresh tokens can only be used by the client for which they were issued.
From the authorization server's point of view, every refresh request is a trust decision. The server asks: "Is this the same client I issued the token to? Has the refresh token been used before? Is it within its maximum lifetime?" When you understand that the server is running a checklist on every refresh call, the client-side patterns in this guide stop feeling like arbitrary rules and start feeling like responses to specific questions the server is asking.
Setting Up Authlib's OAuth2Session
Authlib provides OAuth2Session for synchronous code (built on Requests) and AsyncOAuth2Client for asynchronous code (built on HTTPX). Both share the same API for token management. Authlib requires Python 3.9 or later. Install it with pip:
pip install authlib requests
# For async support:
# pip install authlib httpx
# Verify your version is 1.6.9+ (critical security patches):
# python -c "import authlib; print(authlib.__version__)"
Create a session with your OAuth 2.0 credentials. The token_endpoint parameter is the key to enabling automatic refresh -- when it is set, Authlib knows where to send refresh requests. According to the Authlib Client API References, the OAuth2Session constructor accepts token_endpoint, update_token, and leeway among its keyword arguments.
from authlib.integrations.requests_client import OAuth2Session
client_id = "your-client-id"
client_secret = "your-client-secret"
token_endpoint = "https://auth.example.com/oauth/token"
authorization_endpoint = "https://auth.example.com/oauth/authorize"
session = OAuth2Session(
client_id,
client_secret,
token_endpoint=token_endpoint,
)
Automatic Token Refresh
When you provide token_endpoint during session creation, Authlib automatically refreshes the access token before it expires. As the official OAuth 2 Session documentation states, the token will be refreshed 60 seconds before its expiry time to avoid clock skew issues. You can adjust this buffer with the leeway parameter -- for example, setting leeway=120 triggers the refresh two minutes early instead of one.
from authlib.integrations.requests_client import OAuth2Session
def update_token(token, refresh_token=None, access_token=None):
"""Callback: persist the new token to your database.
Args:
token: The new token dict from the authorization server.
refresh_token: The refresh_token value from the OLD token
(use to locate the existing DB record).
access_token: The access_token value from the OLD token
(used when token was refreshed via client_credentials).
"""
if refresh_token:
# Locate the old token record by its refresh_token value
item = db.find_token_by_refresh(refresh_token)
elif access_token:
# For client_credentials grant, locate by access_token
item = db.find_token_by_access(access_token)
else:
return
# Update the record with the new token values
item.access_token = token["access_token"]
item.refresh_token = token.get("refresh_token")
item.expires_at = token["expires_at"]
item.save()
session = OAuth2Session(
"your-client-id",
"your-client-secret",
token_endpoint="https://auth.example.com/oauth/token",
token_endpoint_auth_method="client_secret_basic",
update_token=update_token, # Called on every refresh
)
# Load an existing token (e.g., from your database)
session.token = {
"access_token": "current-access-token",
"refresh_token": "current-refresh-token",
"token_type": "Bearer",
"expires_at": 1710600000, # Unix timestamp
}
# Make API calls -- token refreshes transparently
response = session.get("https://api.example.com/v1/data")
print(response.json())
The update_token callback fires every time Authlib refreshes the token. This is your hook for persisting the new token to a database, file, or secrets manager. Without this callback, the refreshed token would only exist in memory and would be lost if the application restarts.
When your client refreshes a token 60 seconds before expiry, the server does not know or care about the early timing. It sees a valid refresh token arrive and issues a new access token -- the "early" refresh is invisible server-side. But if your client waits until the access token has already expired and sends an API call with the dead token, the API server rejects it with a 401. Only then does Authlib trigger the refresh, adding a round-trip delay to what should have been a seamless request. The leeway exists to avoid that visible hiccup -- it is a client-side optimization, not a server-side feature.
The update_token Callback (Correct Signature)
A common point of confusion in tutorials and blog posts is the update_token callback signature. The correct signature, as shown in the Authlib Web OAuth Clients documentation, the Flask integration docs, and the HTTPX integration docs, is:
def update_token(token, refresh_token=None, access_token=None):
"""
Correct Authlib update_token signature.
- token: The NEW token dict (contains access_token,
refresh_token, expires_at, etc.)
- refresh_token: The OLD refresh_token string (use to find
the existing record in your database)
- access_token: The OLD access_token string (used for
client_credentials grant lookups)
"""
pass
Many third-party tutorials incorrectly show the signature as def update_token(token, old_token=None). This is wrong and will cause your callback to silently miss the keyword arguments Authlib passes. The actual keyword arguments are refresh_token and access_token, which contain the string values from the previous token -- not the entire old token dict. Always verify callback signatures against the official Authlib API docs.
The refresh_token keyword argument gives you the string value of the old refresh token that was used to obtain the new one. This is your database lookup key: find the existing record by this value, then overwrite it with the new token's fields. The access_token keyword argument serves the same purpose for client_credentials grants, where there is no refresh token but the old access token can be used to locate the record.
update_token(token, old_token=None) instead of the correct signature with refresh_token=None, access_token=None.refresh_token="old-rt-value". Python absorbs the unknown kwarg into **kwargs (if present) or raises a TypeError. Either way, your callback never sees the old refresh token value.invalid_grant. If the server uses reuse detection, it may revoke the entire token family.For framework integrations (Flask, Django), Authlib also supports a signal-based approach. The Flask integration provides a token_update signal with the same parameter signature plus a name parameter identifying the OAuth provider:
# Flask signal-based approach (alternative to passing update_token directly)
from authlib.integrations.flask_client import token_update
@token_update.connect_via(app)
def on_token_update(sender, name, token,
refresh_token=None, access_token=None):
if refresh_token:
item = OAuth2Token.find(name=name, refresh_token=refresh_token)
elif access_token:
item = OAuth2Token.find(name=name, access_token=access_token)
else:
return
item.access_token = token["access_token"]
item.refresh_token = token.get("refresh_token")
item.expires_at = token["expires_at"]
item.save()
To detect token rotation inside the callback, compare the refresh_token keyword argument (the old value) with token.get("refresh_token") (the new value). If they differ, the server has rotated the refresh token and the old one is now invalid. Store the new value immediately.
Manual Token Refresh
Sometimes you need explicit control over when the refresh happens -- for example, if you want to refresh proactively at application startup or on a timer rather than waiting for an API call to trigger it. Authlib supports this with the refresh_token() method, as documented in the OAuth 2 Session guide.
from authlib.integrations.requests_client import OAuth2Session
session = OAuth2Session(
"your-client-id",
"your-client-secret",
)
# Load the previous token from storage
previous_token = load_token_from_database()
# Explicitly refresh before making any calls
new_token = session.refresh_token(
"https://auth.example.com/oauth/token",
refresh_token=previous_token["refresh_token"],
)
# Save the new token
save_token_to_database(new_token)
# Now use the session with the fresh token
session.token = new_token
response = session.get("https://api.example.com/v1/data")
print(response.json())
Persisting Tokens Across Restarts
A token that only exists in memory is lost when your script ends or your server restarts. For production applications, you need to persist tokens to a durable store and reload them at startup. Here is a pattern using a simple JSON file with restricted permissions for local development, and a database for production:
import json
import os
from pathlib import Path
from authlib.integrations.requests_client import OAuth2Session
TOKEN_FILE = Path(".tokens.json")
def load_token():
"""Load token from file (dev) or database (prod)."""
if os.environ.get("ENV") == "production":
return db.get_token(user_id=current_user.id)
if TOKEN_FILE.exists():
return json.loads(TOKEN_FILE.read_text())
return None
def save_token(token, refresh_token=None, access_token=None):
"""Persist token to file (dev) or database (prod).
Uses the correct Authlib callback signature with
refresh_token and access_token keyword arguments.
"""
if os.environ.get("ENV") == "production":
if refresh_token:
item = db.find_token_by_refresh(refresh_token)
elif access_token:
item = db.find_token_by_access(access_token)
else:
return
item.access_token = token["access_token"]
item.refresh_token = token.get("refresh_token")
item.expires_at = token.get("expires_at")
item.save()
else:
TOKEN_FILE.write_text(json.dumps(token, indent=2))
# Restrict file permissions (Unix/macOS)
TOKEN_FILE.chmod(0o600)
session = OAuth2Session(
"your-client-id",
"your-client-secret",
token_endpoint="https://auth.example.com/oauth/token",
update_token=save_token,
)
# Restore the previous token on startup
existing_token = load_token()
if existing_token:
session.token = existing_token
Add .tokens.json to your .gitignore immediately. In production, store tokens in an encrypted database column or a secrets manager such as AWS Secrets Manager or HashiCorp Vault. Refresh tokens have long lifetimes and grant the same access as a password -- treat them with the same level of protection. For a broader look at protecting credentials and secrets in Python applications, see our guide to secure Python coding practices. The RFC 6819, Section 5.2.2 threat model specifically addresses refresh token theft and recommends binding tokens to client credentials, using rotation, and enabling revocation.
Token Rotation and Reuse Detection
Token rotation is a security pattern where the authorization server issues a new refresh token every time the old one is used, and immediately invalidates the old one. This means each refresh token is single-use. RFC 6819, Section 5.2.2.3 codifies this as "Refresh Token Rotation" and recommends it as a countermeasure to token theft. The RFC 9700 (January 2025) goes further, stating that refresh tokens for public clients "MUST be sender-constrained or use refresh token rotation."
If an attacker steals a refresh token and tries to use it after the legitimate client has already exchanged it, the server detects the reuse and can revoke the entire token family -- invalidating all access for that user until they re-authenticate. As Auth0's documentation on refresh token rotation explains, when a previously-used refresh token is sent to the authorization server, the server immediately invalidates the entire chain of tokens descending from the original grant.
From the client side, handling rotation is straightforward: every time you receive a new token response, check whether the refresh_token value has changed and persist the updated value. Authlib's update_token callback gives you the tools to do this automatically.
The authorization server maintains a "token family" -- a lineage of refresh tokens that traces back to the original grant. When refresh token RT-1 is exchanged for RT-2, the server marks RT-1 as used and records that RT-2 descends from it. If RT-1 arrives again (from an attacker or a buggy client), the server sees a used token being replayed. It does not just reject the request -- it walks the entire family tree and revokes RT-2, RT-3, and any access tokens issued from them. The legitimate client's next API call fails, which is the intended behavior: it forces re-authentication and cuts off both the attacker and the potentially-compromised client.
def save_token_with_rotation_check(token,
refresh_token=None,
access_token=None):
"""Persist token and detect rotation.
Uses the correct Authlib callback signature.
The refresh_token arg is the OLD value; token["refresh_token"]
is the NEW value returned by the authorization server.
"""
new_rt = token.get("refresh_token")
if refresh_token and new_rt and refresh_token != new_rt:
# The server rotated the refresh token.
# The old one (refresh_token) is now invalid.
print("Refresh token rotated by server.")
# Locate and update the existing record
if refresh_token:
item = db.find_token_by_refresh(refresh_token)
elif access_token:
item = db.find_token_by_access(access_token)
else:
return
item.access_token = token["access_token"]
item.refresh_token = new_rt
item.expires_at = token.get("expires_at")
item.save()
session = OAuth2Session(
"your-client-id",
"your-client-secret",
token_endpoint="https://auth.example.com/oauth/token",
update_token=save_token_with_rotation_check,
)
If your provider does not rotate refresh tokens, the same refresh token remains valid across multiple uses. In that case, you still need to store it securely, but you do not need the rotation detection logic. Check your provider's documentation to confirm their rotation policy -- providers like Auth0 and Okta offer configurable rotation with grace periods for handling network failures.
Error Handling: What to Do When Refresh Fails
The article so far has shown the happy path: the refresh token is valid, the server responds with a new token pair, and the callback persists it. In production, refreshes fail for a range of reasons, and the failure mode determines whether your application should retry, re-authenticate, or alert an operator. Authlib raises OAuthError (or its subclasses) when the authorization server returns an error response during token refresh, so you can catch these at the session level.
The authorization server returns invalid_grant when the refresh token is expired, revoked, or has already been used (in rotation scenarios). This is the error defined in RFC 6749, Section 5.2. When you receive it, the refresh token is dead -- retrying with the same token will produce the same error. The only recovery path is a full re-authorization flow (redirecting the user back through the consent screen, or prompting for credentials again in a service context).
import logging
import time
import requests
from authlib.integrations.requests_client import OAuth2Session
from authlib.common.errors import AuthlibBaseError
log = logging.getLogger(__name__)
session = OAuth2Session(
"your-client-id",
"your-client-secret",
token_endpoint="https://auth.example.com/oauth/token",
update_token=save_token,
)
session.token = load_token_from_database()
try:
response = session.get("https://api.example.com/v1/data")
response.raise_for_status()
except AuthlibBaseError as e:
if "invalid_grant" in str(e):
# Refresh token is dead -- force re-authentication
clear_stored_token(user_id=current_user.id)
redirect_to_login()
elif "invalid_client" in str(e):
# Client credentials are wrong -- configuration problem
log.critical("OAuth client credentials rejected: %s", e)
raise
else:
# Other OAuth errors (invalid_scope, server_error, etc.)
log.error("OAuth error during token refresh: %s", e)
raise
except requests.exceptions.ConnectionError:
# Network failure -- the refresh token is still valid,
# so retrying is safe
log.warning("Network error during token refresh, will retry")
time.sleep(2)
response = session.get("https://api.example.com/v1/data")
The key distinction is between permanent failures and transient failures. An invalid_grant error means the token is gone and retrying is pointless. A network timeout or a server_error response means the refresh token itself may still be valid and a retry (with backoff) is appropriate. Building this distinction into your error handling prevents both unnecessary re-authentication prompts and infinite retry loops.
Refresh tokens can be invalidated for reasons outside your control: the user changed their password, an admin revoked the grant, the token hit its maximum lifetime, or the provider rotated its signing keys. Your application should always have a code path that handles the "token is permanently dead" case gracefully. For background services and cron jobs, this means logging an alert that requires human attention, not silently failing and swallowing the error.
Concurrent Refresh and Race Conditions
When multiple threads, processes, or server instances share the same OAuth token, a race condition can occur during refresh. If the authorization server uses token rotation (as recommended by RFC 9700), the first process to refresh the token invalidates the old refresh token. Any other process that attempts to refresh with that same now-invalid token receives an invalid_grant error -- and with reuse detection enabled, the server may revoke the entire token family, locking out all processes until the user re-authenticates.
This is not a theoretical concern. It surfaces regularly in production environments where load-balanced web servers, background workers, or multiple CLI sessions share a single set of credentials. The pattern looks like this: Process A and Process B both detect an expired access token at roughly the same time. Process A refreshes first, gets a new token pair, and invalidates the old refresh token. Process B then sends the old (now dead) refresh token, which triggers reuse detection and revokes everything.
The server receives two refresh requests carrying the same refresh token RT-5. It processes the first one: issue RT-6 and a new access token, mark RT-5 as consumed. Milliseconds later, the second request arrives with RT-5. The server checks: "Has RT-5 been used before?" Yes. This triggers the reuse detection alarm. The server does not know whether the second request came from a legitimate client with a timing bug or from an attacker replaying a stolen token. The safe response is the same in both cases: revoke the entire family.
This is why the concurrent refresh problem is a security concern, not just a reliability concern. The server cannot distinguish "two honest processes that did not coordinate" from "one honest process and one attacker." Its only safe move is to shut everything down.
import threading
from authlib.integrations.requests_client import OAuth2Session
# A lock that coordinates refresh across threads
_refresh_lock = threading.Lock()
_shared_token = None
def get_session():
"""Create a session with coordinated token refresh."""
def coordinated_update(token, refresh_token=None,
access_token=None):
global _shared_token
_shared_token = token
save_token_to_database(token, refresh_token=refresh_token,
access_token=access_token)
session = OAuth2Session(
"your-client-id",
"your-client-secret",
token_endpoint="https://auth.example.com/oauth/token",
update_token=coordinated_update,
)
return session
def safe_request(method, url, **kwargs):
"""Make an API request with coordinated refresh.
Uses a lock to ensure only one thread refreshes at a time.
Other threads re-read the stored token after the lock releases.
"""
global _shared_token
with _refresh_lock:
session = get_session()
# Re-read token from storage -- another thread may have
# already refreshed it while we waited for the lock
_shared_token = load_token_from_database()
session.token = _shared_token
response = session.request(method, url, **kwargs)
return response
For multi-process deployments (Gunicorn workers, Celery tasks, separate microservices), a thread lock is not sufficient because the lock only works within a single process. In these cases, use a distributed lock backed by Redis, a database advisory lock, or a dedicated token management service that centralizes refresh operations. The core principle is the same: serialize refresh operations so that only one process refreshes at a time, and every other process re-reads the stored token after the refresh completes.
An alternative to distributed locking is the "read-after-fail" pattern: let any process attempt the refresh, but if a process receives invalid_grant, it re-reads the token from the shared store before giving up. If another process already refreshed successfully, the stored token will be valid and the failing process can continue without re-authentication. This approach is simpler but only works if your provider uses grace periods for recently-rotated tokens (as Okta and Auth0 offer).
Token Revocation (RFC 7009)
Token refresh keeps sessions alive, but the other side of lifecycle management is ending them cleanly. RFC 7009 defines a standard token revocation endpoint that allows clients to notify the authorization server that a token is no longer needed. When you revoke a refresh token, the server invalidates both the refresh token and any access tokens issued from the same authorization grant. This is the mechanism behind "log out" functionality, account deactivation flows, and credential rotation procedures.
Authlib does not provide a built-in client-side revoke_token() convenience method on OAuth2Session, but the revocation call is a straightforward POST request. The revocation endpoint accepts a token parameter and an optional token_type_hint ("refresh_token" or "access_token") to tell the server which type of token is being revoked.
from authlib.integrations.requests_client import OAuth2Session
session = OAuth2Session(
"your-client-id",
"your-client-secret",
)
def revoke_token(session, revocation_url, token_value,
token_type_hint="refresh_token"):
"""Revoke a token per RFC 7009.
Args:
session: An authenticated OAuth2Session.
revocation_url: The provider's revocation endpoint URL.
token_value: The token string to revoke.
token_type_hint: "refresh_token" or "access_token".
"""
response = session.post(
revocation_url,
data={
"token": token_value,
"token_type_hint": token_type_hint,
},
)
# RFC 7009 requires 200 even for invalid tokens
# (the goal -- invalidation -- is already achieved)
if response.status_code == 200:
# Clean up local storage
clear_stored_token(user_id=current_user.id)
else:
log.warning("Revocation returned %s: %s",
response.status_code, response.text)
# On user logout:
revoke_token(
session,
"https://auth.example.com/oauth/revoke",
token_value=current_token["refresh_token"],
token_type_hint="refresh_token",
)
Not every authorization server supports RFC 7009 -- check your provider's documentation for the revocation endpoint URL. Providers like Auth0, Okta, and Google all support it. If the provider does not offer a revocation endpoint, you cannot force server-side invalidation -- you can only delete the token from your local storage and wait for it to expire naturally.
Deleting a token from your database is not the same as revoking it. If an attacker has already copied the token, deleting your copy does not stop them from using it. Server-side revocation via RFC 7009 ensures the token is rejected everywhere. Build revocation into your logout flow, your "disconnect this integration" UI, and any credential rotation procedures.
RFC 7009 requires the revocation endpoint to return HTTP 200 even if the submitted token is already invalid, expired, or does not exist. This is intentional: the client's goal is to ensure the token is no longer usable, and if the token is already dead, that goal is achieved. Returning an error would force the client to distinguish "success" from "already revoked," which adds complexity without security benefit. From the server's perspective, the end state is identical: the token is not usable.
Using AsyncOAuth2Client with HTTPX
For async applications (FastAPI, Starlette, or any asyncio-based code), Authlib provides AsyncOAuth2Client built on HTTPX. As the Authlib HTTPX documentation confirms, it shares the same token management API as the synchronous OAuth2Session, including automatic refresh and the update_token callback. The callback can be either sync or async.
from authlib.integrations.httpx_client import AsyncOAuth2Client
async def update_token(token, refresh_token=None, access_token=None):
"""Async callback for token persistence.
Same signature as the sync version -- Authlib accepts both.
"""
if refresh_token:
item = await OAuth2Token.find(
name=name, refresh_token=refresh_token
)
elif access_token:
item = await OAuth2Token.find(
name=name, access_token=access_token
)
else:
return
item.access_token = token["access_token"]
item.refresh_token = token.get("refresh_token")
item.expires_at = token["expires_at"]
await item.save()
client = AsyncOAuth2Client(
"your-client-id",
"your-client-secret",
token_endpoint="https://auth.example.com/oauth/token",
update_token=update_token,
)
# Load existing token
client.token = await db.get_latest_token()
# Make async API calls with transparent refresh
async with client as c:
response = await c.get("https://api.example.com/v1/data")
print(response.json())
The AsyncOAuth2Client and the synchronous OAuth2Session share the same API for token handling, PKCE, and client authentication methods. If you start with synchronous code and later migrate to async, the token management logic transfers directly -- just add async/await keywords where needed.
Authlib vs requests-oauthlib for Refresh
Both Authlib and requests-oauthlib support token refresh, but they handle it differently. Here is a practical comparison based on each library's official documentation:
| Feature | Authlib | requests-oauthlib |
|---|---|---|
| Auto-refresh | Built in when token_endpoint is set; refreshes 60s before expiry by default (API Docs) |
Requires passing auto_refresh_url and token_updater to the constructor (API Docs) |
| Manual refresh | session.refresh_token(url, refresh_token=...) |
session.refresh_token(url, refresh_token=...) |
| Token update callback | update_token(token, refresh_token=None, access_token=None) -- receives old token's refresh_token and access_token values as keyword args for DB lookup |
token_updater(token) -- receives only the new token dict; no old-token context provided |
| Async support | Full async via AsyncOAuth2Client (HTTPX) (HTTPX Docs) |
No native async support |
| PKCE support | Built-in automatic generation with code_challenge_method='S256' |
Added in v2.0.0 (March 2024) via pkce parameter |
| Leeway control | Configurable leeway parameter (default 60 seconds) for early refresh timing |
No configurable leeway; refreshes on TokenExpiredError after expiry |
| Latest version | v1.6.9 (March 2, 2026) -- actively maintained | v2.0.0 (March 22, 2024) -- less frequent releases; depends on OAuthLib |
Authlib's key advantages for token refresh are the old-token context in the update callback (which enables both database record lookup and rotation detection), the configurable leeway for proactive refresh before expiry, and native async support. If your project already uses requests-oauthlib and works well, there is no urgent need to migrate. For new projects, Authlib provides a more complete and actively maintained foundation for the full OAuth 2.0 token lifecycle.
Key Takeaways
- Upgrade to Authlib 1.6.9+ immediately: Versions prior to 1.6.9 contain CVE-2026-27962 (CVSS 9.1), a critical JWT forgery vulnerability that allows unauthenticated authentication bypass. Run
pip install --upgrade authliband verify withpython -c "import authlib; print(authlib.__version__)". - Use Authlib's
token_endpointfor automatic refresh: When set, Authlib transparently refreshes tokens before they expire. The default 60-second leeway prevents clock-skew issues. Your application code makes API calls without any awareness of token lifecycle. - Use the correct
update_tokensignature: The callback isupdate_token(token, refresh_token=None, access_token=None)-- notupdate_token(token, old_token=None). The keyword args provide the old token's string values for database record lookup, not the entire old token dict. - Handle token rotation per RFC 6819 and RFC 9700: When the server rotates refresh tokens, always store the new value immediately. Compare the
refresh_tokenkeyword argument (old value) withtoken["refresh_token"](new value) to detect rotation. Reuse detection by the authorization server can revoke the entire token chain. - Distinguish permanent from transient refresh failures: An
invalid_granterror means the refresh token is dead and re-authentication is required. Network timeouts andserver_errorresponses are transient and can be retried with backoff. Build both paths into your error handling. - Serialize refresh operations in concurrent environments: When multiple threads, processes, or server instances share a token, use a lock (thread lock for single-process, distributed lock for multi-process) to ensure only one refresh happens at a time. Without coordination, token rotation causes race conditions that can revoke the entire token family.
- Revoke tokens on logout and disconnection (RFC 7009): Deleting a token from your database is not revocation. Call the provider's revocation endpoint to ensure the token is rejected server-side. This is especially important for security events like password changes and integration disconnections.
- Store tokens securely and treat refresh tokens like passwords: Use encrypted database columns or a secrets manager in production. For local development, use a file with restricted permissions (
chmod 0o600) and add it to.gitignore. - Choose Authlib for new projects needing refresh: Its built-in auto-refresh, old-token context in the callback, configurable leeway, and native async support make it the more complete choice for token lifecycle management compared to requests-oauthlib.
Token refresh is the mechanism that turns short-lived security tokens into a seamless user experience. Authlib abstracts the protocol-level details so you can focus on what the tokens unlock rather than how they are renewed. Set the token_endpoint, implement the update_token callback with the correct signature, coordinate refresh across concurrent processes, handle failure cases with the right distinction between permanent and transient errors, and revoke tokens cleanly when sessions end. With those pieces in place, your application has a token lifecycle that handles expiration, rotation, persistence, error recovery, and teardown without a single manual refresh call in your business logic.
Sources and Further Reading
- Authlib Documentation: OAuth 2 Session -- official guide to automatic and manual token refresh
- Authlib Client API References -- OAuth2Session constructor parameters including
update_token,leeway, andtoken_endpoint - Authlib: OAuth for HTTPX -- AsyncOAuth2Client documentation with async
update_tokenexamples - Authlib: Web OAuth Clients -- framework integration patterns for Flask and Django
- Authlib on PyPI -- version history and release notes (v1.6.9, March 2, 2026)
- RFC 6749, Section 1.5 -- The OAuth 2.0 Authorization Framework: Refresh Tokens
- RFC 6819, Section 5.2.2.3 -- OAuth 2.0 Threat Model: Refresh Token Rotation
- RFC 9700 -- Best Current Practice for OAuth 2.0 Security (January 2025)
- RFC 7009 -- OAuth 2.0 Token Revocation: endpoint specification for revoking access and refresh tokens
- Auth0: Refresh Token Rotation -- reuse detection, token family revocation, and grace periods for concurrent requests
- requests-oauthlib API Documentation -- OAuth2Session constructor and
token_updaterparameter