Support

OAuth 2.0 in Python: The Authorization Code Flow Explained

By Simon O'Connor · Updated 18 June 2026 · 12 min read

OAuth lets an app use part of your account without ever seeing your password. You approve access on the provider's own page, the provider redirects back with a short-lived code, and your app exchanges that code for a scoped access token.

This guide explains the authorization code flow in Python: the four roles, the redirect, the code-for-token exchange, state, PKCE, refresh tokens, and the errors you are most likely to hit.

The problem OAuth solves

Without OAuth, the naive solution is to give an app your Spotify email and password. That gives the app everything your account can do, and those credentials leak if the app is breached.

OAuth replaces password sharing with scoped, revocable tokens. You authenticate with Spotify, approve a limited request, and the app receives a token for that approved access only.

The four roles

The OAuth spec talks about four parties, and the jargon is easier once you map each name to who it actually is in the Spotify example. The user is you. The app is the client. Spotify's login system issues the tokens, and Spotify's API holds the data.

Four roles left to right: a Spotify user (resource owner), a listening-history app on a laptop (client), the Spotify Accounts login page (authorization server), and the Spotify API servers (resource server). An access token passes from the login step to the API servers.
Role Who it is Spotify example
Resource ownerThe human who owns the data and grants accessYou, the Spotify user
ClientThe app that wants to act on your behalfThe listening-history app
Authorization serverAuthenticates you and issues tokensaccounts.spotify.com
Resource serverHolds the data and accepts the tokenapi.spotify.com

The useful distinction is between the last two. One server issues the token; the other accepts that token and returns the data.

How the app gets access without your password

Instead of giving your Spotify password to the app, the app sends your browser to Spotify's own login and approval page, where you log in and approve the scope of access you are granting.

After you approve the request, Spotify sends your browser back to the app using a pre-registered return URL called a redirect URI. That return trip includes a short-lived authorization code.

How Spotify does this is by appending the authorization code to the end of your registered redirect URI. If your redirect URI is https://your-app.com/callback, the browser returns to a URL shaped like this: https://your-app.com/callback?code=splat-7291-xyz&state=xyz123.

That code is visible in the browser URL during the return trip, which is why it is not the access token. It is short-lived and useful only as the next step in the exchange.

The app then exchanges that code for an access token. In modern OAuth flows, that exchange should include the PKCE code_verifier. For a confidential server-side app, it may also use the prearranged client_secret.

The token is what lets the app call the API. Your password stays with Spotify.

Here's the full flow

Before the flow can run, you must register your app in the provider's developer dashboard. You provide the exact redirect URI where users should be sent after approval, and the provider issues a public client_id. Server-side apps may also receive a confidential client_secret, while native apps, CLIs, and browser apps usually rely on PKCE instead.

Once that setup exists, the authorization code flow looks like this.

  1. Send the user to the authorization URL. The app redirects the browser to the authorization server with a query string containing response_type=code, your client_id, the redirect_uri, the scope you are requesting, a random state value, and a PKCE code_challenge.
  2. The user logs in and approves. On the provider's own page, the user signs in and sees what access the app is requesting. The user can approve or deny the request.
  3. The provider redirects back. If the user approves, the authorization server sends the browser back to your registered redirect_uri with ?code=...&state=... appended.
  4. Exchange the code for tokens. Your app sends the code to the token endpoint with grant_type=authorization_code, the same redirect_uri, your client_id, and the original PKCE code_verifier. Confidential server-side apps may also authenticate with a client_secret. The provider returns an access_token and, often, a refresh_token.
  5. Call the API. The app calls the resource server with the access token in an Authorization: Bearer <access_token> header.

The code you write is mostly the authorization redirect, the callback route, the token exchange, and the API request that uses the token.

Building the authorization URL

Step two is just a URL with a carefully built query string. The app does not call this endpoint itself; it sends the user's browser to it. We build the URL in Python and hand it to a redirect.

auth.py
import base64
import hashlib
import secrets
from urllib.parse import urlencode

AUTH_URL = "https://accounts.example.com/authorize"
CLIENT_ID = "your-client-id"
REDIRECT_URI = "http://127.0.0.1:8080/callback"


def make_pkce_pair():
    code_verifier = secrets.token_urlsafe(64)
    digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    return code_verifier, code_challenge


# A fresh random value per request, stored in the session for later.
state = secrets.token_urlsafe(16)
code_verifier, code_challenge = make_pkce_pair()

# In a real web route, store these in the user's session before redirecting:
# session["oauth_state"] = state
# session["code_verifier"] = code_verifier

params = {
    "response_type": "code",
    "client_id": CLIENT_ID,
    "redirect_uri": REDIRECT_URI,
    "scope": "playlist-read-private",
    "state": state,
    "code_challenge": code_challenge,
    "code_challenge_method": "S256",
}

authorization_url = f"{AUTH_URL}?{urlencode(params)}"
print(authorization_url)

The important values are response_type=code, your client_id, the exact redirect_uri, the requested scope, a random state value you store so you can check it on return, and the PKCE code_challenge. Store the original code_verifier server side too, because you need it during the token exchange.

Handling the callback and exchanging the code

After the user approves, the provider redirects the browser to your redirect_uri with the code attached. This is the route you have to build. It reads the code, checks the state, and trades the code for a token from your server. Here it is as a minimal Flask route.

callback.py
import os
import secrets

import requests
from flask import Flask, request, session, abort

app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]

TOKEN_URL = "https://accounts.example.com/api/token"
CLIENT_ID = "your-client-id"
CLIENT_SECRET = "your-client-secret"
REDIRECT_URI = "http://127.0.0.1:8080/callback"


@app.route("/callback")
def callback():
    error = request.args.get("error")
    code = request.args.get("code")
    returned_state = request.args.get("state")
    expected_state = session.pop("oauth_state", None)
    code_verifier = session.pop("code_verifier", None)

    if error:
        abort(400, f"Authorization failed: {error}")

    if not code:
        abort(400, "Authorization callback did not include a code.")

    # Reject the callback if the state does not match what we sent.
    if not returned_state or not expected_state:
        abort(400, "Missing OAuth state.")

    if not secrets.compare_digest(returned_state, expected_state):
        abort(400, "State mismatch. Possible CSRF attempt.")

    if not code_verifier:
        abort(400, "Missing PKCE code verifier.")

    token_response = requests.post(
        TOKEN_URL,
        data={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": REDIRECT_URI,
            "client_id": CLIENT_ID,
            "code_verifier": code_verifier,
            "client_secret": CLIENT_SECRET,
        },
        timeout=10,
    )
    token_response.raise_for_status()
    tokens = token_response.json()

    access_token = tokens["access_token"]
    refresh_token = tokens.get("refresh_token")
    return "Authorized. Access token acquired."

The token request is a server-side POST, not a browser redirect. It includes the original PKCE code_verifier, which proves this is the same app instance that started the login. It returns JSON containing an access_token, usually an expiry time, and often a refresh_token.

Using the access token

With the token in hand, calling the resource server is the easy part. The token goes in an Authorization header, prefixed with the word Bearer and a space.

Python
import requests

headers = {"Authorization": f"Bearer {access_token}"}

response = requests.get(
    "https://api.example.com/v1/me/playlists",
    headers=headers,
    timeout=10,
)
response.raise_for_status()
playlists = response.json()

From here on, the API call is ordinary HTTP. The resource server validates the token, checks the scopes, and returns the data.

Access tokens and refresh tokens

Access tokens are deliberately short-lived, often an hour. That limits the damage if one leaks: a stolen token expires on its own. But you do not want to send the user back through the login flow every hour, which is what the refresh token is for.

The refresh token is a longer-lived credential returned alongside the access token. When the access token expires, your server POSTs to the same token endpoint with grant_type=refresh_token and the refresh token, and receives a fresh access token without any user interaction. The flow runs once; the refresh keeps it alive.

Python
import requests

token_response = requests.post(
    "https://accounts.example.com/api/token",
    data={
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    },
    timeout=10,
)
token_response.raise_for_status()
new_access_token = token_response.json()["access_token"]

Because the refresh token is long-lived and powerful, it deserves the same care as a password: store it server side, never expose it to the browser, and revoke it if you suspect it has leaked.

The state parameter

state protects the callback from CSRF. Generate a random value before the redirect, store it in the user's session, send it in the authorization URL, and reject the callback unless the returned value matches. After checking it, remove it from the session so the same callback cannot be replayed. A callback that contains a code but fails the state check did not come from a flow your app started.

Redirect URIs must match exactly

The redirect_uri must match the dashboard exactly: same scheme, host, port, and path. http://localhost:5000/callback and http://127.0.0.1:5000/callback are different strings even if they reach the same machine. If you see redirect_uri_mismatch, copy the registered URI into your code character for character.

PKCE for modern code flows

Mobile apps, single-page apps, and CLI tools cannot safely store a client_secret. PKCE protects them with a one-time secret per login flow. Current OAuth security guidance also recommends PKCE for confidential clients when the provider supports it, because it binds the authorization response to the token exchange.

The client creates a random code_verifier, sends its SHA-256 hash as the code_challenge, then sends the original verifier during the token exchange. A stolen authorization code is useless without the matching verifier.

Python
import base64
import hashlib
import secrets

# A high-entropy random string, kept for the token exchange.
code_verifier = secrets.token_urlsafe(64)

# The challenge is the URL-safe base64 of the SHA-256 hash, no padding.
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

# Send code_challenge + "S256" in the auth request; send code_verifier at exchange.

Confidential server-side apps that already authenticate with a client_secret may still be accepted without PKCE by some providers, but PKCE adds protection at little cost, and the trend across providers is to recommend it for every client regardless of type. If you are starting fresh, add it unless your provider explicitly tells you not to.

Authorization code or client credentials?

Choose the flow by asking who the app is acting for. The authorization code flow is for an app acting on behalf of a person. It sends that person to the provider to sign in and approve access, then returns them to the app with a code.

The client credentials flow is for machine-to-machine access, where the application acts as itself and no person signs in. A background service might use it to call an API owned by the same organisation. There is no browser redirect or consent screen; the client authenticates directly with the token endpoint. Do not use client credentials when the API call needs access to a particular user's account.

Question Authorization code Client credentials
Who is represented?A signed-in userThe application itself
Browser and consent?YesNo
Typical useAccessing a user's accountService-to-service automation

The rest of this guide stays with the authorization code flow. If your provider supports client credentials, follow its documentation for the token endpoint, scopes, and client-authentication method rather than adapting the browser-based script below.

The script you can grab

Here is the flow assembled into one runnable script. It uses Python's built-in http.server to catch the redirect, requests for HTTP, and PKCE by default. Replace the constants at the top with your provider's values.

oauth_script.py
import base64
import hashlib
import json
import secrets
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse

import requests


# Replace these with values from your provider's developer dashboard.
AUTHORIZATION_ENDPOINT = "https://accounts.example.com/oauth/authorize"
TOKEN_ENDPOINT = "https://accounts.example.com/oauth/token"
CLIENT_ID = "replace-with-your-client-id"
CLIENT_SECRET = ""  # Leave blank for PKCE-only public clients.
REDIRECT_URI = "http://127.0.0.1:8080/callback"
SCOPES = ["read:profile", "read:data"]
API_TEST_URL = "https://api.example.com/v1/me"

TOKEN_FILE = Path("oauth-tokens.json")
REQUEST_TIMEOUT = (3.05, 27)


class OAuthCallbackHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        query = parse_qs(urlparse(self.path).query)

        self.server.auth_code = query.get("code", [None])[0]
        self.server.returned_state = query.get("state", [None])[0]
        self.server.error = query.get("error", [None])[0]

        if self.server.error:
            status = 400
            message = f"Authorization failed: {self.server.error}"
        else:
            status = 200
            message = "Authorization complete. You can close this tab."

        self.send_response(status)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers()
        self.wfile.write(message.encode("utf-8"))

    def log_message(self, format, *args):
        return


def make_pkce_pair():
    """Return a PKCE code_verifier and its S256 code_challenge."""
    code_verifier = secrets.token_urlsafe(64)
    digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    return code_verifier, code_challenge


def build_authorization_url(state, code_challenge):
    params = {
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": " ".join(SCOPES),
        "state": state,
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",
    }
    return f"{AUTHORIZATION_ENDPOINT}?{urlencode(params)}"


def wait_for_callback():
    parsed = urlparse(REDIRECT_URI)
    host = parsed.hostname or "127.0.0.1"
    port = parsed.port or 80

    server = HTTPServer((host, port), OAuthCallbackHandler)
    server.auth_code = None
    server.returned_state = None
    server.error = None

    server.handle_request()

    if server.error:
        raise RuntimeError(f"Authorization failed: {server.error}")
    if not server.auth_code:
        raise RuntimeError("Authorization callback did not include a code.")

    return server.auth_code, server.returned_state


def normalize_tokens(token_response, previous_tokens=None):
    tokens = dict(previous_tokens or {})
    tokens.update(token_response)

    if "expires_in" in token_response:
        tokens["expires_at"] = int(time.time()) + int(token_response["expires_in"])

    return tokens


def save_tokens(tokens):
    TOKEN_FILE.write_text(json.dumps(tokens, indent=2), encoding="utf-8")


def load_tokens():
    if not TOKEN_FILE.exists():
        return None
    return json.loads(TOKEN_FILE.read_text(encoding="utf-8"))


def exchange_code_for_tokens(code, code_verifier):
    data = {
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID,
        "code_verifier": code_verifier,
    }

    if CLIENT_SECRET:
        data["client_secret"] = CLIENT_SECRET

    response = requests.post(
        TOKEN_ENDPOINT,
        data=data,
        headers={"Accept": "application/json"},
        timeout=REQUEST_TIMEOUT,
    )
    response.raise_for_status()

    tokens = normalize_tokens(response.json())
    save_tokens(tokens)
    return tokens


def authorize_in_browser():
    state = secrets.token_urlsafe(24)
    code_verifier, code_challenge = make_pkce_pair()
    authorization_url = build_authorization_url(state, code_challenge)

    print("Opening browser for OAuth approval...")
    webbrowser.open(authorization_url)

    code, returned_state = wait_for_callback()

    if returned_state != state:
        raise RuntimeError("State mismatch. Refusing to exchange the code.")

    return exchange_code_for_tokens(code, code_verifier)


def token_is_expired(tokens):
    # Refresh one minute early so a token does not expire mid-request.
    return time.time() >= tokens.get("expires_at", 0) - 60


def refresh_tokens(tokens):
    refresh_token = tokens.get("refresh_token")
    if not refresh_token:
        return authorize_in_browser()

    data = {
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": CLIENT_ID,
    }

    if CLIENT_SECRET:
        data["client_secret"] = CLIENT_SECRET

    response = requests.post(
        TOKEN_ENDPOINT,
        data=data,
        headers={"Accept": "application/json"},
        timeout=REQUEST_TIMEOUT,
    )

    if response.status_code in (400, 401):
        print("Refresh token was rejected. Starting a fresh browser login.")
        return authorize_in_browser()

    response.raise_for_status()
    fresh_tokens = normalize_tokens(response.json(), previous_tokens=tokens)
    save_tokens(fresh_tokens)
    return fresh_tokens


def get_valid_tokens():
    tokens = load_tokens()

    if not tokens:
        return authorize_in_browser()
    if token_is_expired(tokens):
        return refresh_tokens(tokens)

    return tokens


def api_get(url):
    tokens = get_valid_tokens()
    headers = {"Authorization": f"Bearer {tokens['access_token']}"}

    response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)

    if response.status_code == 401:
        tokens = refresh_tokens(tokens)
        headers = {"Authorization": f"Bearer {tokens['access_token']}"}
        response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)

    response.raise_for_status()
    return response.json()


if __name__ == "__main__":
    data = api_get(API_TEST_URL)
    print(json.dumps(data, indent=2))

Run it like this:

Terminal
python -m pip install requests
python oauth_script.py

The first run opens your browser and writes oauth-tokens.json. Later runs reuse that file and refresh the token when they can. Add oauth-tokens.json to .gitignore: it is a credential file.

.gitignore
oauth-tokens.json

What the script is doing

The five parts you have already met, now assembled into one file:

  • PKCE. make_pkce_pair() creates the one-time code_verifier and its hashed code_challenge.
  • State. authorize_in_browser() generates the random state and checks it comes back unchanged.
  • Loopback redirect. wait_for_callback() runs a one-shot local server on 127.0.0.1:8080 to catch the redirect.
  • Code exchange. exchange_code_for_tokens() trades the code for an access token and a refresh token.
  • Refresh. get_valid_tokens() reuses saved tokens and refreshes them before they expire.

Common OAuth errors and how to read them

OAuth errors look cryptic until you map each one to the step that produced it. Ask first: did it fail before login, on the callback, during the token exchange, or while calling the API? These are the ones you will actually meet.

Error Usually means Fix
redirect_uri_mismatchThe redirect URI sent by Python does not exactly match the dashboard.Copy the registered URI into REDIRECT_URI character for character.
invalid_clientThe client ID or client authentication is wrong.Check CLIENT_ID, secret handling, and whether the token endpoint expects Basic auth.
invalid_grantThe code expired, was already used, the verifier is wrong, or the refresh token was revoked.Run the flow again and make sure the same code_verifier reaches the token exchange.
invalid_scopeOne or more scopes are misspelled or not allowed for the app.Use the provider's exact scope names and enable the API in the dashboard if required.
401 UnauthorizedThe access token is missing, expired, revoked, or sent to the wrong API.Refresh the token, then confirm the API URL belongs to the same provider and app.
403 ForbiddenThe token is valid but does not have permission for that operation.Request the right scope, re-approve, and try again.

Don't hand-roll this in production

The script is useful for learning and personal tools, but it is not what you should ship to other people. Production apps should use a maintained OAuth library for state checks, PKCE, refresh logic, token storage, and provider-specific edge cases.

  • Authlib for full OAuth and OpenID Connect support.
  • requests-oauthlib if you want OAuth added to the requests style you already know.
  • spotipy if you are working specifically with Spotify.

The book's free Chapter 14 covers OAuth against a live API in exactly this spirit: understand the flow, then build it with the right tools rather than from scratch.

Frequently asked questions

What is the difference between OAuth and a password?

A password unlocks your entire account, and sharing it with an app gives that app the power to do anything you can. OAuth never shares your password. You authenticate on the provider's own page and approve a specific, limited request, and the app receives a scoped token instead. That token can be revoked at any time without changing your password, and the app only ever has the access you granted, not the keys to everything.

What is the difference between an access token and a refresh token?

An access token is a short-lived key, often valid for about an hour, that you put in the Authorization header to call the API. A refresh token is a longer-lived credential used to obtain new access tokens when the current one expires, by posting to the token endpoint with grant_type=refresh_token. The refresh token lets you keep calling the API without sending the user through the login flow again.

Do I need PKCE for a server-side web app?

Use PKCE if your provider supports it. PKCE was designed for public clients that cannot store a secret, such as mobile apps, single-page apps, and CLI tools, but current OAuth security guidance also recommends it for confidential server-side apps because it binds the authorization response to the token exchange. Some older providers still treat PKCE as optional for server-side apps, but for new work it is a good default.

What is the difference between authorization code and client credentials?

The authorization code flow lets an application act on behalf of a signed-in user, so it includes a browser redirect and the user's approval. The client credentials flow lets an application act as itself, usually for service-to-service automation, so no user signs in. Use authorization code when you need a person's account data or permissions; use client credentials only when the API grants access directly to the application.

Can I use OAuth from a plain Python script?

Yes. Register the script as a native, desktop, installed, or public client if the provider offers that option, then use the authorization code flow with PKCE: open the user's browser for approval, listen on a loopback redirect URI such as http://127.0.0.1:8080/callback, and exchange the returned code for tokens from Python. The script above does exactly this with nothing but requests and the standard library.


Next Step:

You have walked the full authorization code flow. Chapter 14 of Mastering APIs with Python builds it into a working GitHub CLI tool with state, PKCE, and safe token handling, and that chapter is free to read in full.