How to Implement Token Refresh and Rotation for Python API Authentication with Authlib

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.

The Mental Model: A Token Is a Lease, Not a Key

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.

Security Advisory: Upgrade to Authlib 1.6.9

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.

Think Like the Auth Server

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.

THREAT → DEFENSE
Stolen access token used for unauthorized API calls
Short-lived expiry (15-60 min) limits the window of exploitation. The attacker's access dies when the token expires, and they cannot renew it without the refresh token.

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.

The 60-Second Leeway, From the Server's Perspective

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
Common Mistake: old_token Parameter

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.

Failure Cascade: Wrong Callback Signature
1
You define update_token(token, old_token=None) instead of the correct signature with refresh_token=None, access_token=None.
2
Authlib calls your callback with keyword arguments 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.
3
Without the old refresh token to locate the database record, you either overwrite the wrong record, create a duplicate, or silently skip the save.
4
On the next application restart, you reload a stale token from the database. If the server uses rotation, that old refresh token is already dead. You get invalid_grant. If the server uses reuse detection, it may revoke the entire token family.
5
Every user session backed by that token chain is now locked out. The user must re-authenticate from scratch. In a background service with no interactive user, the service is dead until an operator intervenes.

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()
Pro Tip

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

Decision: Automatic vs Manual Refresh
IF
Your app makes API calls reactively (web requests, user actions) → use automatic refresh. Let Authlib handle it transparently when the next API call happens.
IF
Your app runs on a schedule (cron, task queue) or needs a valid token at startup before any API call → use manual refresh at startup, then let automatic refresh handle the rest.
IF
You need to refresh tokens for users who are not currently active (pre-warming, batch processing) → use manual refresh exclusively. There is no session to auto-refresh because there is no active HTTP request.

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
Security Warning

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.

THREAT → DEFENSE
Refresh token extracted from plaintext config file, unencrypted database column, or accidentally committed to version control
Encrypted storage + restricted file permissions limit exposure. Secrets managers add access logging and automatic rotation. Even if the storage layer is breached, rotation and revocation provide secondary defenses (covered in the next two sections).

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.

What Reuse Detection Looks Like Internally

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.

THREAT → DEFENSE
Attacker steals a refresh token and uses it after the legitimate client has already refreshed
Token rotation + reuse detection catches the replay. The server revokes the entire token family, forcing re-authentication. The attacker's stolen token is dead, and the legitimate client is alerted by the sudden authentication failure.
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.

Decision: Retry, Re-authenticate, or Escalate?
invalid_grant
The refresh token is permanently dead. Clear the stored token, redirect users to re-authenticate, or (for services) alert an operator. Never retry.
invalid_client
Your client credentials are wrong or revoked. This is a configuration error, not a runtime issue. Fix the credentials and redeploy. Never retry automatically.
server_error
The auth server is having a bad day. Retry with exponential backoff (2s, 4s, 8s). The refresh token is still valid. Cap at 3-5 retries before escalating.
Network error
Connection failure, DNS resolution failure, or timeout. Same as server_error -- retry with backoff. The refresh token has not been consumed because the request never reached the server.
Refresh Tokens Die Silently

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

builds on → Token Rotation + Error Handling

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 Race, From the Server's Perspective

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.

Pro Tip

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.

THREAT → DEFENSE
Token copied from database backup, log file, or compromised server continues to work after the user "logs out"
Server-side revocation via RFC 7009 ensures the token is rejected at the authorization server regardless of who presents it. Deleting the token from your database only removes your copy -- revocation kills it everywhere.

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.

Always Revoke on Logout

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.

Why the Server Returns 200 for Invalid Tokens

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())
Note

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

  1. 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 authlib and verify with python -c "import authlib; print(authlib.__version__)".
  2. Use Authlib's token_endpoint for 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.
  3. Use the correct update_token signature: The callback is update_token(token, refresh_token=None, access_token=None) -- not update_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.
  4. Handle token rotation per RFC 6819 and RFC 9700: When the server rotates refresh tokens, always store the new value immediately. Compare the refresh_token keyword argument (old value) with token["refresh_token"] (new value) to detect rotation. Reuse detection by the authorization server can revoke the entire token chain.
  5. Distinguish permanent from transient refresh failures: An invalid_grant error means the refresh token is dead and re-authentication is required. Network timeouts and server_error responses are transient and can be retried with backoff. Build both paths into your error handling.
  6. 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.
  7. 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.
  8. 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.
  9. 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