Endpoint 51 Support

OAuth 2.0 in Python: The Authorization Code Flow Explained

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

The first time an app asks to use your Spotify account, something quietly clever happens. You click a button, you land on Spotify's own page, you approve, and you bounce back. The app can now read your playlists. At no point did you type your Spotify password into that app. That small dance is OAuth 2.0, and the version you just walked through is the authorization code flow.

Most developers meet OAuth as a wall of jargon: resource owners, bearer tokens, redirect URIs, PKCE. It looks like cryptography homework. It is not. The authorization code flow is a sequence of plain HTTP requests with a clear purpose behind each one, and once you can name that purpose, the mechanics stop being mysterious.

This guide explains the flow from the ground up. You will see the four roles in play, why the whole thing bounces through a redirect instead of just handing over a token, how the code-for-token exchange works, and what state and PKCE are protecting you from. There are Python sketches throughout to make it concrete, but the goal is understanding, not a finished login system. For anything real you should lean on a vetted library, and we will name the good ones at the end.

The problem OAuth solves

Picture an app that builds a time machine of your listening history. To do its job, it needs to read your Spotify account. The naive way to grant that access is to type your Spotify email and password into the app's own form. Do not do this. You would be handing a third party the keys to your entire account: it could change your password, read your saved cards, do anything you can do. And if that app is ever breached, your Spotify credentials leak with it.

OAuth exists to avoid exactly that. Instead of giving the app your password, you authenticate on Spotify's own page, approve a specific, limited request ("this app wants to read your playlists"), and Spotify hands the app a token. The token is scoped to what you approved, it can be revoked at any time without changing your password, and the app never sees your credentials at all. That is the whole idea: delegated, scoped, revocable access without sharing the secret that unlocks everything.

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.

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 split that trips people up is the last two. The authorization server and the resource server are often run by the same company, but they do different jobs. One decides whether you may grant access and mints the token. The other does not care how you got the token; it just checks that the token is valid and returns the data. Keeping them separate in your head explains why the flow has two distinct network destinations: one to get a token, another to use it.

Why a redirect, not just a token

The defining move of the authorization code flow is that it sends the user away. Rather than the app collecting your password and forwarding it, the app redirects your browser to the authorization server's own login page. You authenticate there, on a page you can verify belongs to Spotify, and the app is never in the room when you type your password.

That indirection is the entire point. Because authentication happens on the provider's domain, your credentials only ever touch the provider. The client comes away with proof that you approved, never the secret behind it. And the proof it receives first is deliberately weak: a short-lived authorization code, not the token itself. The code is close to useless on its own. It must be exchanged, from the app's server, for the real token in a second request the browser never sees.

This two-step shape, a code in the browser then a token on the server, is why it is called the authorization code flow. The valuable credential, the access token, is only ever handled server side, out of reach of the browser, the URL bar, and anything watching the redirect.

The flow, step by step

Here is the whole dance in order. Read it once end to end; each later section zooms into one of these steps with code.

  1. Register your app. Before any of this works, you register your app with the provider's developer dashboard. You get a client_id and a client_secret, and you register one or more exact redirect URIs the provider is allowed to send users back to.
  2. Send the user to the authorization URL. The app redirects the browser to the authorization server with a query string: response_type=code, your client_id, the redirect_uri, the scope you are requesting, and a random state value.
  3. The user logs in and approves. On the provider's own page, the user authenticates and sees exactly what access is being requested, then approves or denies it.
  4. The provider redirects back. On approval, the authorization server sends the browser back to your redirect_uri with ?code=...&state=... appended.
  5. Exchange the code for tokens. Your server POSTs the code to the token endpoint with grant_type=authorization_code, the code, the same redirect_uri, your client_id, and your client_secret. Back comes an access_token and usually a refresh_token.
  6. Call the API. You make requests to the resource server with the token in an Authorization: Bearer <access_token> header.

Six steps, two of which (registration and the user's own login) you barely write code for. The parts you build are the redirect, the callback, and the token exchange. The next sections take them in turn.

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 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:5000/callback"

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

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

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

Every parameter earns its place. response_type=code says we want the authorization code flow rather than one of the other grant types. client_id identifies the app. redirect_uri tells the provider where to send the user back, and it must match a value you registered. scope is the list of permissions you are asking for, space separated, and the user sees these on the consent screen. state is a random token we will check on the way back, and we store it in the user's session now so we can compare it later.

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 requests
from flask import Flask, request, session, abort

app = Flask(__name__)
app.secret_key = "replace-with-a-real-secret"

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:5000/callback"


@app.route("/callback")
def callback():
    code = request.args.get("code")
    returned_state = request.args.get("state")

    # Reject the callback if the state does not match what we sent.
    if not returned_state or returned_state != session.get("state"):
        abort(400, "State mismatch. Possible CSRF attempt.")

    token_response = requests.post(
        TOKEN_URL,
        data={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": REDIRECT_URI,
            "client_id": CLIENT_ID,
            "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."

Notice the shape of the exchange. The token request is a POST with a form-encoded data= body, not a query string, and it carries the client_secret. That is why it lives on the server and not in the browser: the secret must never travel anywhere a user could read it. The response is JSON, typically with access_token, a token_type of Bearer, an expires_in in seconds, and a refresh_token. We call raise_for_status() so a rejected exchange surfaces as an error instead of a confusing KeyError a line later.

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

That is the whole reason the flow exists: a header on a normal request. The resource server does not know or care how you obtained the token. It validates the token, checks that its scopes cover what you are asking for, and returns the data. From here on, an OAuth-protected API behaves like any other API call.

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

The state value looked like a throwaway random string, but it is doing real security work. It is your defence against cross-site request forgery on the callback. You generate a random value, send it in the authorization request, and the provider returns the same value untouched in the redirect. On the callback, you check that the returned value matches the one you stored. If it does not, the callback did not originate from a flow you started, and you reject it.

Skipping the state check leaves you open to CSRF

If your callback accepts a code without verifying state, an attacker can trick a logged-in user into completing a flow the attacker initiated, linking the victim's session to the attacker's account. The signature is a callback route that reads request.args.get("code") and exchanges it immediately, with no comparison against a value stored when the flow began. Generate the state, store it in the session, and compare it on return, every time.

Redirect URIs must match exactly

The redirect_uri you send in the authorization request, and again in the token exchange, must match one you registered in the developer dashboard exactly: same scheme, host, port, and path, character for character. This is not pedantry. An open redirect would let an attacker steal authorization codes by pointing the flow at a URL they control, so providers refuse anything that is not a registered, exact match.

localhost and 127.0.0.1 are not interchangeable

A redirect URI is matched as a literal string, so http://localhost:5000/callback and http://127.0.0.1:5000/callback are two different URIs even though they resolve to the same machine. Spotify, since a 2026 change, requires the loopback IP 127.0.0.1 rather than localhost for local development redirect URIs. The signature is an "invalid redirect URI" error during local testing that looks baffling because the address "works" in a browser. Register and send the exact form the provider expects. The Spotify tutorial walks through this for a real project.

PKCE for public clients

The flow so far leans on the client_secret to prove the token request is genuine. That works when the app has a server to keep the secret on. But a mobile app, a single-page app, or a CLI tool cannot keep a secret: anyone can unpack the binary or read the JavaScript. PKCE, Proof Key for Code Exchange, secures the flow for those public clients without a stored secret.

The idea is a one-time secret generated per flow. The client invents a random code_verifier, hashes it with SHA-256, and sends that hash as the code_challenge (with code_challenge_method=S256) in the authorization request. At the token exchange, it sends the original code_verifier. The server hashes the verifier and checks it against the challenge it saw earlier. A stolen authorization code is now useless to anyone who does not also hold 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 not strictly need PKCE, but it adds a layer at little cost, and the trend across providers is to recommend PKCE for every client regardless of type. If you are starting fresh, adding it is rarely a mistake.

Don't hand-roll this in production

The sketches above are for understanding. They are not what you should ship. Every example here has omitted things real code needs: secure token storage, handling the user denying access, expiry and refresh timing, and the careful constant-time comparisons that security details quietly depend on. The flow is simple to follow and easy to get subtly wrong.

Understand the flow, then let a library run it

The value of walking through the steps by hand is that the library stops being a black box: you know what authorize and fetch_token are doing under the hood. But once you understand it, hand the mechanics to a maintained, audited library rather than reimplementing state checks, PKCE, and refresh logic yourself. Getting the security details right once, in well-tested code, beats getting them slightly wrong everywhere.

  • Authlib is a comprehensive OAuth and OpenID Connect library for clients and servers, with integrations for Flask, Django, and plain requests.
  • requests-oauthlib bolts OAuth onto the requests API you already know, which keeps the learning curve short if you are coming from plain HTTP calls.
  • spotipy is a dedicated Spotify client that handles the authorization code flow, PKCE, and token refresh for you, so the example in this guide becomes a few lines.

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?

PKCE was designed for public clients that cannot store a secret, such as mobile apps, single-page apps, and CLI tools. A confidential server-side app already authenticates with a client_secret, so it is not strictly required there. That said, PKCE adds a layer of protection at little cost, and providers increasingly recommend it for every client type, so adding it to a server-side app is rarely a mistake.

Mastering APIs with Python

OAuth is the gate between calling toy endpoints and integrating with real services people actually use. In the full book, you build authenticated clients against live APIs and make them production-ready: token handling, refresh, error handling, and tests, across six portfolio projects covering Flask, OAuth, SQLite, Postgres, Docker, CI/CD, and AWS.

Get the book for €35

Chapter 3 is free to read.