Tutorial

FastAPI Auth: OAuth 2.0 — The Authorization Framework

OAuth 2.0 is not authentication — it is authorization. Here are the four grant types, why PKCE is now mandatory, and how to implement the authorization code flow in FastAPI with working code and sequence diagrams.

Tin Dang avatar
Tin Dang
A flow diagram showing three parties — user, application, and authorization server — exchanging tokens

A developer integrates “Login with Google” using OAuth 2.0 and assumes the access token proves who the user is. It does not. The access token proves that a user authorized your app to access their Google data — it says nothing about who that user is. Six months later, a security audit reveals that any valid Google user can impersonate any other user because the app never checked the sub claim in an ID token that was never requested.

OAuth 2.0 is an authorization framework, not an authentication protocol. It answers “What can this app do?” not “Who is this person?” Conflating the two is the most common OAuth mistake, and it has real security consequences.

This post explains what OAuth 2.0 actually is, walks through all four grant types, shows why PKCE replaced the implicit flow, and implements the authorization code flow in FastAPI.

What OAuth 2.0 Actually Is

OAuth 2.0 solves a specific problem: delegated access. How do you let a third-party application access your data on another service without giving it your password?

Before OAuth, the answer was: give the app your username and password. The app logs in as you, scrapes the data it needs. If you want to revoke access, you change your password — and break every other app using the same credentials.

OAuth introduces a token-based delegation model:

The Four Roles

RoleWhoExample
Resource OwnerThe user who owns the dataYou
ClientThe application requesting accessA task manager app
Authorization ServerIssues tokens after consentGoogle’s OAuth server
Resource ServerHosts the protected dataGmail API

The Four Grant Types

OAuth 2.0 defines four ways (grant types) for a client to obtain an access token. Each is designed for a different client type:

Grant Type 1: Authorization Code (Server-Side Apps)

The gold standard. Used when the client is a server-side application that can securely store a client secret.

Why this is secure: The authorization code is exchanged for tokens on the back channel (server-to-server). The access token never passes through the browser. The client secret proves the client’s identity to the authorization server.

Grant Type 2: Authorization Code + PKCE (SPAs & Mobile)

SPAs and mobile apps cannot store a client secret — the code is visible to the user. PKCE (Proof Key for Code Exchange, pronounced “pixie”) replaces the client secret with a cryptographic challenge.

How PKCE prevents interception: Even if an attacker intercepts the authorization code, they cannot exchange it for a token — they do not have the code_verifier that matches the code_challenge. The verifier never leaves the client application.

# PKCE helper
import hashlib
import base64
import secrets
def generate_pkce_pair() -> tuple[str, str]:
"""Generate a PKCE code_verifier and code_challenge."""
verifier = secrets.token_urlsafe(32)
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
return verifier, challenge

Grant Type 3: Client Credentials (Machine-to-Machine)

No user involved. The client authenticates as itself (not on behalf of a user) using its client ID and secret.

This is the OAuth equivalent of API key authentication — but with standard token endpoints, automatic expiry, and scope-based access control.

Grant Types 4 & 5: Implicit and ROPC (Deprecated)

Implicit Grant: Returns the access token directly in the URL fragment (#access_token=...). No back-channel exchange. The token is exposed in browser history, referrer headers, and logs. Deprecated by OAuth 2.1 — use PKCE instead.

Resource Owner Password Credentials (ROPC): The user gives their password directly to the client app, which exchanges it for a token. Defeats the entire purpose of OAuth (not sharing passwords). Deprecated by OAuth 2.1 — never implement this.

Implementing OAuth 2.0 (Authorization Code) in FastAPI

Let’s implement “Login with GitHub” for CloudDocs.

Configuration

src/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# GitHub OAuth
github_client_id: str
github_client_secret: str
github_redirect_uri: str = "http://localhost:8000/auth/github/callback"
# Your app
jwt_secret_key: str
frontend_url: str = "http://localhost:3000"
model_config = {"env_file": ".env"}

Step 1: Initiate the OAuth Flow

src/api/v1/oauth.py
import secrets
from urllib.parse import urlencode
from fastapi import APIRouter, Request, Response
from fastapi.responses import RedirectResponse
from src.core.config import settings
router = APIRouter(prefix="/auth/github", tags=["oauth"])
@router.get("/login")
async def github_login(request: Request):
"""Redirect user to GitHub's authorization page."""
state = secrets.token_urlsafe(32)
# Store state in session/cookie to verify on callback
params = urlencode({
"client_id": settings.github_client_id,
"redirect_uri": settings.github_redirect_uri,
"scope": "user:email read:user",
"state": state,
})
response = RedirectResponse(f"https://github.com/login/oauth/authorize?{params}")
response.set_cookie(
key="oauth_state",
value=state,
httponly=True,
secure=True,
samesite="lax",
max_age=600, # 10 minutes
)
return response

The state parameter prevents CSRF attacks. Without it, an attacker could initiate an OAuth flow with their own account and trick you into linking your application profile to their identity.

Step 2: Handle the Callback

import httpx
@router.get("/callback")
async def github_callback(
code: str,
state: str,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
# Verify state matches
stored_state = request.cookies.get("oauth_state")
if not stored_state or not secrets.compare_digest(stored_state, state):
raise HTTPException(status_code=400, detail="Invalid OAuth state")
# Exchange code for access token (back-channel)
async with httpx.AsyncClient() as client:
token_response = await client.post(
"https://github.com/login/oauth/access_token",
json={
"client_id": settings.github_client_id,
"client_secret": settings.github_client_secret,
"code": code,
"redirect_uri": settings.github_redirect_uri,
},
headers={"Accept": "application/json"},
)
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Failed to obtain access token")
# Fetch user profile from GitHub
async with httpx.AsyncClient() as client:
user_response = await client.get(
"https://api.github.com/user",
headers={"Authorization": f"Bearer {access_token}"},
)
github_user = user_response.json()
# Also fetch email (might be private)
email_response = await client.get(
"https://api.github.com/user/emails",
headers={"Authorization": f"Bearer {access_token}"},
)
emails = email_response.json()
primary_email = next(
(e["email"] for e in emails if e["primary"] and e["verified"]),
None,
)
if not primary_email:
raise HTTPException(status_code=400, detail="No verified email from GitHub")
# Find or create user
user = await find_or_create_oauth_user(
db,
provider="github",
provider_id=str(github_user["id"]),
email=primary_email,
display_name=github_user.get("name") or github_user["login"],
)
# Issue your own JWT
jwt_token = create_access_token(user_id=str(user.id), roles=user.roles)
# Clear the OAuth state cookie
response.delete_cookie("oauth_state")
# Redirect to frontend with token
return RedirectResponse(
f"{settings.frontend_url}/auth/callback?token={jwt_token}"
)

The Full Flow Visualized

OAuth 2.0 Comparison Matrix

Grant TypeUser Involved?Client Secret?PKCE?Best ForStatus
Authorization CodeYesYesOptionalServer-side web appsRecommended
Auth Code + PKCEYesNoRequiredSPAs, mobile appsRecommended
Client CredentialsNoYesNoMachine-to-machineRecommended
ImplicitYesNoNo(was for SPAs)Deprecated
ROPCYesOptionalNo(was for trusted apps)Deprecated

Common OAuth Mistakes

  1. Using OAuth for authentication: OAuth tells you what the app can do, not who the user is. For authentication, you need OpenID Connect (Part 6).

  2. Not validating the state parameter: Without state verification, an attacker can perform a CSRF attack on the callback endpoint.

  3. Storing the client secret in frontend code: SPAs cannot keep secrets. Use PKCE.

  4. Requesting overly broad scopes: Ask for the minimum scopes needed. user:email instead of user on GitHub. Users are more likely to grant narrow permissions.

  5. Not handling token expiry: Access tokens expire. Implement the refresh flow or handle 401 responses gracefully.

What’s Next

In Part 6, we add the missing identity layer. OpenID Connect extends OAuth 2.0 with standardized user identity — the id_token that tells you who the user is, not just what they authorized. We will implement Google SSO and compare OIDC with SAML.

0

Next in this series

FastAPI Auth: OpenID Connect and Single Sign-On

Continue reading