How to Handle OAuth 2.0 Authorization Code Flow in Python for Third-Party API Integration

The authorization code flow is the OAuth 2.0 grant type you will use whenever your Python application needs to access a user's data on a third-party service -- GitHub repositories, Google Drive files, Spotify playlists, or any API that requires user consent. Unlike client credentials (machine-to-machine) or API keys (static tokens), this flow involves redirecting the user to the provider's login page, receiving a temporary authorization code, and exchanging it for an access token. If you are new to building APIs with Python, start there for the foundational concepts. This guide walks through each step with working code using both requests-oauthlib and Authlib, adds PKCE for security, and shows how to persist tokens for long-running applications.

The authorization code flow exists because users should never have to give their password to a third-party application. Instead, the user authenticates directly with the service they trust (GitHub, Google, etc.), approves the specific permissions your application is requesting, and the provider sends a one-time code back to your application. Your server then exchanges this code -- along with your client secret -- for an access token that lets you make API calls on the user's behalf. The access token has a limited lifetime and can be scoped to restrict exactly what your application can do.

How the Authorization Code Flow Works

The flow involves four sequential steps. First, your application redirects the user's browser to the provider's authorization URL with your client ID, requested scopes, a redirect URI, and a random state parameter. Second, the user logs in (if not already) and approves the requested permissions. Third, the provider redirects the user back to your redirect URI with an authorization code and the state parameter in the URL. Fourth, your server sends a POST request to the provider's token endpoint with the authorization code, client ID, and client secret, and receives an access token (and optionally a refresh token) in response.

The state parameter is critical for security. It is a random string your application generates before redirecting the user. When the provider redirects back, your application checks that the returned state matches what it stored. If it does not match, the request may be a cross-site request forgery (CSRF) attack and must be rejected.

INTERACTIVE FLOW VISUALIZER -- click any step to inspect
USER (BROWSER)
YOUR APP (SERVER)
PROVIDER (GITHUB, GOOGLE...)
generate state + PKCE verifier
Your server creates a cryptographically random state string and a PKCE code_verifier/code_challenge pair. Both are stored server-side (session or database) before the user leaves your domain. This is the foundation -- everything that follows depends on these values being unpredictable and tied to this specific user's session.
clicks "Connect GitHub"
302 redirect to provider
Your server responds with an HTTP 302 redirect. The Location header encodes client_id, redirect_uri, scope, state, and code_challenge as query parameters on the provider's authorization URL. The user's browser follows the redirect automatically.
THREAT: open redirect if URI not exact-matched
DEFENSE: OAuth 2.1 exact string matching
logs in + approves scopes
authenticates user, shows consent
The provider handles authentication directly. Your application never sees the user's password. The consent screen shows exactly which scopes are being requested, giving the user a final decision point. This is the core principle of delegated authorization: the trust boundary sits at the provider, not your app.
browser follows redirect
302 redirect to your callback + code + state
The provider redirects the user back to your redirect_uri with a temporary authorization code and the same state value. This code is single-use and short-lived (typically 10 minutes). It travels through the user's browser in the URL, which is why it cannot be used alone -- it needs the server-side secret to become a token.
THREAT: code interception in transit
DEFENSE: PKCE binds code to verifier
verify state, then POST code + verifier + secret
validate code + challenge, issue tokens
Your server first compares the returned state with the stored value -- a mismatch means a potential CSRF attack. If valid, it sends a back-channel POST to the provider's token endpoint with the code, client_secret, and code_verifier. The provider checks the code, verifies the PKCE challenge, and responds with an access token and (optionally) a refresh token. This exchange happens server-to-server, never through the browser.
THREAT: CSRF if state not validated
DEFENSE: state parameter + session binding
store tokens, call API on user's behalf
return protected resources
Your server stores the access token (and refresh token if present) securely. All subsequent API requests include the access token in the Authorization: Bearer header. The token's scopes limit what your application can access. When the access token expires, your server uses the refresh token to get a new one -- without requiring the user to re-authorize.
MENTAL MODEL

Think of the authorization code flow like a coat check at a venue. You (the user) hand your coat (your data) to the venue staff (the provider) -- not to the person who invited you (the third-party app). The venue gives your friend a numbered ticket (the authorization code). Your friend takes the ticket to the desk and shows their ID (client secret) to pick up your coat. The ticket is useless without the ID, the ID is useless without a valid ticket, and at no point did your friend need to know which locker your coat is in.

Registering Your Application with a Provider

Before writing any code, you need to register your application with the OAuth 2.0 provider. For GitHub, this is done at github.com/settings/applications/new. For Google, it is the Google Cloud Console under APIs & Services > Credentials. The registration process gives you a client ID and a client secret, and requires you to specify one or more redirect URIs -- the URLs where the provider will send the user after authorization.

Security Warning

Store your client secret in an environment variable or secrets manager. Never commit it to source code. The redirect URI must match exactly between your provider registration and your code -- even a trailing slash difference will cause the flow to fail. For a comprehensive guide to handling secrets and other secure Python coding practices, see our dedicated article on the topic.

Implementation with requests-oauthlib

The requests-oauthlib library wraps OAuthLib and the Requests library to handle the entire authorization code flow. Here is the complete process using GitHub as the provider:

import os
from requests_oauthlib import OAuth2Session

CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
CLIENT_SECRET = os.environ["GITHUB_CLIENT_SECRET"]
AUTHORIZATION_URL = "https://github.com/login/oauth/authorize"
TOKEN_URL = "https://github.com/login/oauth/access_token"
REDIRECT_URI = "http://localhost:5000/callback"
SCOPE = ["read:user", "user:email"]

# Step 1: Build the authorization URL
github = OAuth2Session(
    CLIENT_ID, redirect_uri=REDIRECT_URI, scope=SCOPE
)
authorization_url, state = github.authorization_url(
    AUTHORIZATION_URL
)
print(f"Visit this URL to authorize:\n{authorization_url}")

# Save 'state' for CSRF validation (in a web app,
# store it in the user's session)
saved_state = state

# Step 2: User visits the URL, logs in, and approves access
# GitHub redirects back to REDIRECT_URI with ?code=...&state=...

# Step 3: Exchange the authorization code for tokens
redirect_response = input("Paste the full callback URL: ")
github = OAuth2Session(
    CLIENT_ID, state=saved_state, redirect_uri=REDIRECT_URI
)
token = github.fetch_token(
    TOKEN_URL,
    client_secret=CLIENT_SECRET,
    authorization_response=redirect_response,
)
print(f"Access token: {token['access_token']}")

# Step 4: Use the token to access protected resources
response = github.get("https://api.github.com/user")
print(response.json())

The OAuth2Session object manages the state parameter, token storage, and header formatting automatically. After fetch_token() succeeds, all subsequent requests made through the same session object include the access token in the Authorization: Bearer header.

Without the state parameter, an attacker can execute a cross-site request forgery attack that links their own account to the victim's session:

1. Attacker starts the OAuth flow on your app, gets redirected to GitHub, and logs into their own GitHub account.
2. Attacker copies the callback URL (containing their authorization code) before their browser follows it.
3. Attacker tricks the victim into visiting that callback URL (via email, embedded image, or forum post).
4. Your app exchanges the code for tokens and stores them in the victim's session -- now the victim's account is linked to the attacker's GitHub.
5. Any data the victim pushes "to GitHub" through your app now goes to the attacker's repositories.

The state parameter stops this at step 4: the victim's session has a different state value than the one in the attacker's URL, so your app rejects the callback.

Building a Flask Callback Handler

In a real web application, the user is redirected to the authorization URL via an HTTP response, and the callback is handled by a route in your web framework. Here is the complete flow in Flask:

import os
from flask import Flask, redirect, request, session, jsonify
from requests_oauthlib import OAuth2Session

app = Flask(__name__)
app.secret_key = os.urandom(24)

CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
CLIENT_SECRET = os.environ["GITHUB_CLIENT_SECRET"]
AUTHORIZATION_URL = "https://github.com/login/oauth/authorize"
TOKEN_URL = "https://github.com/login/oauth/access_token"
REDIRECT_URI = "http://localhost:5000/callback"

@app.route("/login")
def login():
    github = OAuth2Session(CLIENT_ID, redirect_uri=REDIRECT_URI)
    authorization_url, state = github.authorization_url(
        AUTHORIZATION_URL
    )
    session["oauth_state"] = state
    return redirect(authorization_url)

@app.route("/callback")
def callback():
    github = OAuth2Session(
        CLIENT_ID,
        state=session["oauth_state"],
        redirect_uri=REDIRECT_URI,
    )
    token = github.fetch_token(
        TOKEN_URL,
        client_secret=CLIENT_SECRET,
        authorization_response=request.url,
    )
    session["oauth_token"] = token
    return redirect("/profile")

@app.route("/profile")
def profile():
    github = OAuth2Session(
        CLIENT_ID, token=session["oauth_token"]
    )
    user = github.get("https://api.github.com/user").json()
    return jsonify(username=user["login"], name=user["name"])
Pro Tip

During local development, OAuth 2.0 requires HTTPS for the redirect URI. To bypass this for testing, set os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" before your app runs. Never use this in production.

Adding PKCE for Stronger Security

PKCE (Proof Key for Code Exchange) adds a per-request secret to the authorization code exchange, preventing interception attacks. The upcoming OAuth 2.1 specification requires PKCE for all authorization code flows. With requests-oauthlib, you generate the PKCE parameters manually:

import hashlib, base64, secrets

def generate_pkce_pair():
    verifier = base64.urlsafe_b64encode(
        secrets.token_bytes(32)
    ).rstrip(b"=").decode()
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode()).digest()
    ).rstrip(b"=").decode()
    return verifier, challenge

code_verifier, code_challenge = generate_pkce_pair()

# Include in the authorization URL
authorization_url, state = github.authorization_url(
    AUTHORIZATION_URL,
    code_challenge=code_challenge,
    code_challenge_method="S256",
)

# Include verifier when exchanging the code
token = github.fetch_token(
    TOKEN_URL,
    authorization_response=callback_url,
    code_verifier=code_verifier,
)

Without PKCE, the authorization code is the only credential needed to complete the token exchange (for public clients). An attacker on the same network, or malware on the device, can intercept the code in transit:

1. User completes authorization at GitHub and is redirected back with ?code=abc123 in the URL.
2. A malicious app registered to handle the same custom URI scheme (mobile) or a network observer (WiFi) captures the code from the redirect.
3. The attacker sends the stolen code to the token endpoint with the public client_id (no secret required for public clients).
4. The provider issues an access token to the attacker. The legitimate app's exchange fails because the code was already consumed.

With PKCE, step 3 fails: the attacker does not have the code_verifier that was generated in the legitimate app's memory and never transmitted. The provider rejects the exchange.

Implementation with Authlib

COMPREHENSION CHECKPOINT
PKCE binds the authorization code to a per-request secret. What happens if an attacker intercepts the authorization code but does not have the code verifier?
The provider stored the code_challenge (a SHA-256 hash of the verifier) when the flow began. During the token exchange, the provider hashes the submitted code_verifier and compares it to the stored challenge. No match, no token. This is what makes PKCE effective even if the authorization code transits through an insecure channel -- the code alone is not enough.

Authlib provides a cleaner interface for the same flow, with built-in PKCE support and automatic token refresh. Here is the equivalent GitHub integration:

from authlib.integrations.requests_client import OAuth2Session

client = OAuth2Session(
    client_id=os.environ["GITHUB_CLIENT_ID"],
    client_secret=os.environ["GITHUB_CLIENT_SECRET"],
    redirect_uri="http://localhost:5000/callback",
    scope="read:user user:email",
    code_challenge_method="S256",  # PKCE built in
)

# Step 1: Generate authorization URL (PKCE auto-handled)
uri, state = client.create_authorization_url(
    "https://github.com/login/oauth/authorize"
)

# Step 2: After callback, exchange code for token
token = client.fetch_token(
    "https://github.com/login/oauth/access_token",
    authorization_response=callback_url,
)

# Step 3: Access protected resources
resp = client.get("https://api.github.com/user")
print(resp.json())
Note

With Authlib, setting code_challenge_method="S256" on the session is all you need for PKCE. The library generates the verifier and challenge automatically, stores them internally, and includes the verifier when exchanging the code for a token.

Persisting and Refreshing Tokens

For web applications, storing tokens in the Flask session works during a single browser session but is lost when the user closes the browser. For long-running integrations (like a background sync service), you need to persist tokens to a database and refresh them when they expire.

from authlib.integrations.requests_client import OAuth2Session

def save_token(token, old_token=None):
    """Persist the token to your database."""
    db.upsert_user_token(
        user_id=current_user.id,
        access_token=token["access_token"],
        refresh_token=token.get("refresh_token"),
        expires_at=token.get("expires_at"),
    )

client = OAuth2Session(
    client_id=os.environ["GOOGLE_CLIENT_ID"],
    client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
    token_endpoint="https://oauth2.googleapis.com/token",
    update_token=save_token,  # Called on every refresh
)

# Load an existing token from the database
client.token = db.get_user_token(current_user.id)

# Make API calls -- token refreshes transparently
files = client.get(
    "https://www.googleapis.com/drive/v3/files"
)
print(files.json())

When the token_endpoint is set and the token includes a refresh_token, Authlib automatically refreshes the access token before it expires and calls the update_token callback so you can persist the new token. For Google, you need to request the access_type=offline parameter during authorization to receive a refresh token.

MENTAL MODEL

An access token is like a day pass to a building -- it gets you through the door, but it expires at midnight. A refresh token is like the employee badge that lets you print a new day pass at the kiosk each morning. If someone steals your day pass, they have access until the end of the day. If someone steals your employee badge, they can print unlimited day passes until you report it stolen. That is why refresh tokens must be stored with the same care as passwords, and revoked the moment they are no longer needed.

Revoking Tokens When Access Is No Longer Needed

Token persistence solves the problem of keeping access alive, but what about the opposite -- removing access that is no longer wanted? When a user disconnects your integration, deletes their account, or simply wants to revoke the permissions they granted, your application needs to invalidate the tokens it holds. Letting stale tokens sit in a database is a security liability. A compromised token that was never revoked gives an attacker persistent access to the user's resources.

OAuth 2.0 token revocation is defined in RFC 7009. The client sends a POST request to the provider's revocation endpoint with the token it wants to invalidate. The provider responds with HTTP 200 whether the token was successfully revoked or was already invalid -- this prevents attackers from probing which tokens are active.

import requests

def revoke_token(token, token_type_hint="access_token"):
    """Revoke a token at the provider's revocation endpoint."""
    response = requests.post(
        "https://oauth2.googleapis.com/revoke",
        params={"token": token},
        headers={
            "Content-Type": "application/x-www-form-urlencoded"
        },
    )
    # RFC 7009: 200 means revoked or already invalid
    if response.status_code == 200:
        return True
    # Non-200 means the server could not process the
    # revocation -- log and retry or alert
    return False

# Revoke both tokens when the user disconnects
revoke_token(stored_token["access_token"])
if "refresh_token" in stored_token:
    revoke_token(
        stored_token["refresh_token"],
        token_type_hint="refresh_token",
    )

# Then delete the tokens from your database
db.delete_user_token(current_user.id)

With Authlib, revocation is available directly on the session object if the provider supports it:

# Authlib provides built-in revocation support
client.revoke_token(
    "https://oauth2.googleapis.com/revoke",
    token=stored_token["refresh_token"],
    token_type_hint="refresh_token",
)
Security Warning

Always revoke the refresh token, not just the access token. A revoked access token expires on its own, but a live refresh token can generate new access tokens indefinitely. When a user disconnects your integration, revoke the refresh token first, then delete all stored token data from your database.

ATTACK CHAIN: Stale Token Exploitation

Unrevoked tokens in a database are an attacker's persistence mechanism. If your database is breached months after a user disconnects, every token you failed to revoke is a live credential. The attack chain is short and devastating:

DB breach --> extract refresh tokens --> exchange for fresh access tokens --> access user data at provider

Handling Errors in the OAuth Flow

The authorization code flow has several points where things can go wrong, and your application needs to handle each one gracefully. The user might deny consent, the authorization code might expire before you exchange it, the provider might return an unexpected error, or a network issue might interrupt the token exchange. Without explicit error handling, these failures surface as cryptic exceptions or silent data loss.

The OAuth 2.0 specification defines a set of error codes that providers return as query parameters on the callback URL when something goes wrong during authorization. The two you will encounter regularly are access_denied (the user clicked "deny" on the consent screen) and invalid_scope (you requested a scope the provider does not recognize or the user is not authorized to grant).

from flask import Flask, redirect, request, session, jsonify
from requests_oauthlib import OAuth2Session
from oauthlib.oauth2.rfc6749.errors import (
    OAuth2Error,
    MismatchingStateError,
    InvalidGrantError,
)

@app.route("/callback")
def callback():
    # Check for provider-returned errors first
    error = request.args.get("error")
    if error:
        description = request.args.get(
            "error_description", "No details provided"
        )
        if error == "access_denied":
            return jsonify(
                message="You declined authorization."
            ), 403
        return jsonify(
            message=f"OAuth error: {error}",
            detail=description,
        ), 400

    try:
        github = OAuth2Session(
            CLIENT_ID,
            state=session.get("oauth_state"),
            redirect_uri=REDIRECT_URI,
        )
        token = github.fetch_token(
            TOKEN_URL,
            client_secret=CLIENT_SECRET,
            authorization_response=request.url,
        )
    except MismatchingStateError:
        # State mismatch -- possible CSRF attack
        return jsonify(
            message="State validation failed."
        ), 403
    except InvalidGrantError:
        # Code expired or was already used
        return jsonify(
            message="Authorization code expired. "
                    "Please try again."
        ), 400
    except OAuth2Error as exc:
        return jsonify(
            message=f"Token exchange failed: {exc}"
        ), 502

    session["oauth_token"] = token
    return redirect("/profile")

Two errors deserve special attention. A MismatchingStateError means the state parameter in the callback does not match what your application stored in the session -- this is a strong indicator of a CSRF attack and should be logged as a security event. An InvalidGrantError typically means the authorization code expired (codes are single-use and short-lived, usually 10 minutes or less) or was already exchanged. In both cases, the correct response is to restart the flow by redirecting the user back to the authorization URL.

Scope Strategy and the Principle of Least Privilege

Scopes control what your application can do with a user's data, and requesting too many is a security risk that extends well beyond your own code. Every scope you request widens the blast radius if your tokens are ever compromised. An attacker who steals a token scoped to read:user can view profile information. An attacker who steals a token scoped to repo can read, write, and delete every repository the user has access to. The difference in damage is enormous.

The principle of least privilege applies directly to OAuth scopes: request only the permissions your application needs for its current operation, and nothing more. If your application only reads a user's profile and email, do not request write access. If your application does not need repository access, do not include repo in the scope list just because you might need it later.

# Bad: requesting broad access upfront
SCOPE = ["repo", "user", "admin:org", "gist"]

# Good: requesting only what is needed right now
SCOPE = ["read:user", "user:email"]

# Better: requesting incrementally as features
# require new permissions
INITIAL_SCOPE = ["read:user", "user:email"]
REPO_SCOPE = ["read:user", "user:email", "public_repo"]
NARROW SCOPE -- IF TOKEN IS STOLEN
read:userview profile
user:emailread email
WIDE SCOPE -- IF TOKEN IS STOLEN
reporead/write/delete all repos
usermodify profile + emails
admin:orgmanage all orgs + teams
gistcreate/delete gists

Incremental authorization is the stronger pattern. Your application starts with the minimum scopes it needs and requests additional permissions only when the user accesses a feature that requires them. Google supports this directly with the include_granted_scopes=true parameter, which merges newly granted scopes with previously granted ones without forcing the user to re-approve everything. GitHub requires a new authorization flow for additional scopes but preserves existing grants.

Pro Tip

Audit your scope requests periodically. Applications tend to accumulate scopes over time as features are added, but those scopes often remain after the features that required them are removed. Treat scope declarations the same way you treat dependency lists -- review them regularly and remove anything that is no longer justified.

What OAuth 2.1 Removes and Why It Matters

PKCE becoming mandatory is only one part of what OAuth 2.1 changes. The specification (draft-ietf-oauth-v2-1, currently at draft 15 as of March 2026) consolidates a decade of security lessons into a single document that replaces RFC 6749 and RFC 6750. Understanding what it removes is important because it shapes which patterns your code should follow going forward.

OAuth 2.1 eliminates two grant types entirely. The implicit grant (response_type=token) returned access tokens directly in the URL fragment, which exposed them through browser history, referrer headers, and server logs. It was originally designed for single-page applications that could not perform backend token exchanges, but modern browsers support cross-origin requests (CORS), making the authorization code flow with PKCE a viable and far safer replacement. The resource owner password credentials (ROPC) grant let applications collect the user's username and password directly and exchange them for tokens. This broke the core design principle of OAuth -- that users should never give their credentials to a third-party application -- and introduced risks around credential phishing, keylogging, and insecure storage.

Beyond removing these grants, OAuth 2.1 makes three other changes that affect how you write authorization code flow implementations:

# OAuth 2.1 requirements for authorization code flows:

# 1. PKCE is mandatory for ALL clients (not just public)
client = OAuth2Session(
    client_id=CLIENT_ID,
    client_secret=CLIENT_SECRET,
    code_challenge_method="S256",  # Required in 2.1
)

# 2. Redirect URIs must use exact string matching
# No wildcards, no substring matching, no open
# redirects. Register the full URI with the provider.
REDIRECT_URI = "https://app.example.com/callback"
# Not: "https://app.example.com/*"
# Not: "https://*.example.com/callback"

# 3. Refresh tokens for public clients must be either
# sender-constrained or one-time use (rotated on each
# refresh). This prevents stolen refresh tokens from
# being replayed indefinitely.

If you are starting a new project today, build to OAuth 2.1 requirements even if the providers you integrate with still accept OAuth 2.0 patterns. The specification is already being adopted by organizations and libraries across the ecosystem, including Anthropic's Model Context Protocol (MCP) authorization specification. Writing to the stricter standard now means less migration work later and better security from the start.

MENTAL MODEL

OAuth 2.0 was like a building code that said "you should probably install smoke detectors." OAuth 2.1 says "smoke detectors are mandatory, and we are banning the types of wiring that caused fires." The implicit grant and ROPC grant were the faulty wiring -- convenient shortcuts that introduced structural risk. PKCE, exact redirect matching, and refresh token rotation are the smoke detectors that were always available but rarely installed. OAuth 2.1 makes the safe path the only path.

Provider-Specific Differences

While the authorization code flow follows the same specification across providers, each one has quirks in how they implement it:

Provider Authorization URL Notes
GitHubgithub.com/login/oauth/authorizeRedirect URI is optional in the request if set in app settings. Does not issue refresh tokens by default.
Googleaccounts.google.com/o/oauth2/authRequires access_type=offline and prompt=consent for refresh tokens. Scopes use full URLs like googleapis.com/auth/drive.readonly.
Microsoftlogin.microsoftonline.com/{tenant}/oauth2/v2.0/authorizeTenant ID required in URL. Supports the offline_access scope for refresh tokens.
Spotifyaccounts.spotify.com/authorizeScopes are space-separated. Always issues a refresh token on first authorization.

Always consult the provider's documentation for the exact authorization and token endpoint URLs, required scopes, and any provider-specific parameters needed to receive refresh tokens.

CONCEPT WEB -- hover a node to see its connections
Every concept in this article connects to others. Hover any node to trace the relationships.
COMPREHENSION CHECKPOINT
Your application stores refresh tokens in a database. A user deletes their account. What is the correct sequence?
Deleting the token from your database only removes your copy. The token remains valid at the provider until it expires or is explicitly revoked. A database breach months later would not recover a revoked token. Always revoke at the provider first (targeting the refresh token, which can mint new access tokens), then clean up your local storage.

Key Takeaways

  1. The authorization code flow is the standard for user-facing integrations: Whenever your application needs to act on behalf of a user with a third-party API, this is the flow to use. The user authenticates directly with the provider, and your application never sees their password.
  2. Always validate the state parameter: The state parameter prevents CSRF attacks by ensuring the callback came from a request your application initiated. Both requests-oauthlib and Authlib generate and validate it automatically, but only if you store it in the session and pass it to the callback handler.
  3. Add PKCE to every authorization code flow: PKCE prevents authorization code interception by binding the code to a per-request secret. Authlib supports it with a single parameter. OAuth 2.1 makes PKCE mandatory for all clients, including confidential ones.
  4. Persist tokens for long-running integrations: Flask sessions are fine for interactive web apps. Background services need tokens stored in a database with automatic refresh. Authlib's update_token callback fires on every refresh, giving you a hook for persistence.
  5. Revoke tokens when they are no longer needed: Stale tokens in a database are a security liability. When a user disconnects your integration, revoke the refresh token through the provider's revocation endpoint and delete all stored token data.
  6. Handle every failure point in the flow explicitly: Users deny consent, authorization codes expire, and token exchanges fail. Check for provider-returned error parameters before attempting the token exchange, catch library-specific exceptions, and log state mismatches as potential security events.
  7. Request the minimum scopes your application needs: Every additional scope widens the damage if a token is compromised. Use incremental authorization to add permissions only when the user accesses features that require them.
  8. Build to OAuth 2.1 requirements now: The implicit and password grants are gone. PKCE is mandatory. Redirect URIs must match exactly. Refresh tokens for public clients must be rotated. Writing to the stricter standard today means less migration work later.
  9. Account for provider-specific quirks: Each provider implements OAuth 2.0 slightly differently -- different scopes, different parameters for refresh tokens, different redirect URI requirements. Always test against the provider's documentation, not just the specification.

The authorization code flow is the backbone of third-party API integration in Python. Whether you are pulling a user's GitHub repositories, syncing their Google Drive files, or reading their Spotify library, the same pattern applies: redirect, authorize, exchange, access. The libraries handle the protocol mechanics. Your job is to handle the edges -- errors, revocation, scope discipline, and the security posture that keeps your users' data safe after the token is issued.