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
| Role | Who | Example |
|---|---|---|
| Resource Owner | The user who owns the data | You |
| Client | The application requesting access | A task manager app |
| Authorization Server | Issues tokens after consent | Google’s OAuth server |
| Resource Server | Hosts the protected data | Gmail 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 helperimport hashlibimport base64import 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, challengeGrant 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
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
import secretsfrom urllib.parse import urlencode
from fastapi import APIRouter, Request, Responsefrom 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 responseThe 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 Type | User Involved? | Client Secret? | PKCE? | Best For | Status |
|---|---|---|---|---|---|
| Authorization Code | Yes | Yes | Optional | Server-side web apps | Recommended |
| Auth Code + PKCE | Yes | No | Required | SPAs, mobile apps | Recommended |
| Client Credentials | No | Yes | No | Machine-to-machine | Recommended |
| Implicit | Yes | No | No | (was for SPAs) | Deprecated |
| ROPC | Yes | Optional | No | (was for trusted apps) | Deprecated |
Common OAuth Mistakes
-
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).
-
Not validating the
stateparameter: Without state verification, an attacker can perform a CSRF attack on the callback endpoint. -
Storing the client secret in frontend code: SPAs cannot keep secrets. Use PKCE.
-
Requesting overly broad scopes: Ask for the minimum scopes needed.
user:emailinstead ofuseron GitHub. Users are more likely to grant narrow permissions. -
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.