Python OAuth2 with FastAPI: Google and GitHub Login
OAuth2 social login removes the burden of password management and gives users a familiar sign-in experience. FastAPI paired with Authlib makes the implementation clean and secure. This guide walks through the authorization code flow, building Google and GitHub login endpoints, exchanging codes for tokens, fetching user profiles, and issuing your own JWT session tokens.
Table of Contents
The Authorization Code Flow
The OAuth2 authorization code flow has four steps: (1) redirect the user to the provider's authorization URL with a state parameter; (2) the provider redirects back to your callback URL with a code; (3) your server exchanges the code for an access token at the provider's token endpoint; (4) your server fetches the user's profile and creates or updates the user record. The state parameter prevents CSRF attacks — always validate it.
Browser Your FastAPI Server Google
| | |
|-- GET /auth/google ------>| |
| |-- redirect to Google ----->|
|<-- 302 to Google.com -----| |
| |
|-- User approves ---------------------------------------->|
|<-- redirect to /auth/google/callback?code=xxx&state=yyy --|
| | |
|-- GET /callback?code=xxx->| |
| |-- POST /token (code) ----->|
| |<-- access_token ------------|
| |-- GET /userinfo ------------->|
| |<-- {email, name, picture} ----|
| |-- issue JWT session token
|<-- Set-Cookie: session ---|
Project Setup and Dependencies
pip install fastapi uvicorn authlib httpx starlette itsdangerous python-dotenv
# .env file
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
SESSION_SECRET=your-random-secret-min-32-chars
APP_BASE_URL=http://localhost:8000
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
load_dotenv()
app = FastAPI(title="OAuth2 Demo")
app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"])
GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GOOGLE_CLIENT_SECRET = os.environ["GOOGLE_CLIENT_SECRET"]
GITHUB_CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
GITHUB_CLIENT_SECRET = os.environ["GITHUB_CLIENT_SECRET"]
BASE_URL = os.environ["APP_BASE_URL"]
Google OAuth2 Login
Register your app in Google Cloud Console under APIs & Services → Credentials. Create an OAuth 2.0 Client ID with the redirect URI http://localhost:8000/auth/google/callback. Enable the Google+ API or People API for user info access.
import secrets
from authlib.integrations.httpx_client import AsyncOAuth2Client
from fastapi import Request
from fastapi.responses import RedirectResponse
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
GOOGLE_REDIRECT_URI = f"{BASE_URL}/auth/google/callback"
@app.get("/auth/google")
async def google_login(request: Request):
state = secrets.token_urlsafe(16)
request.session["oauth_state"] = state
client = AsyncOAuth2Client(
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
redirect_uri=GOOGLE_REDIRECT_URI,
scope="openid email profile",
)
url, _ = client.create_authorization_url(GOOGLE_AUTH_URL, state=state)
return RedirectResponse(url)
@app.get("/auth/google/callback")
async def google_callback(request: Request, code: str, state: str):
# Validate state to prevent CSRF
if state != request.session.pop("oauth_state", None):
return {"error": "Invalid state parameter"}
client = AsyncOAuth2Client(
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
redirect_uri=GOOGLE_REDIRECT_URI,
)
# Exchange authorization code for access token
token = await client.fetch_token(GOOGLE_TOKEN_URL, code=code)
# Fetch user info
resp = await client.get(GOOGLE_USERINFO_URL)
user_info = resp.json()
# user_info = {"sub": "1234567890", "email": "user@gmail.com", "name": "Jane Doe", ...}
# Upsert user in database and issue session
user = await upsert_user(
provider="google",
provider_id=user_info["sub"],
email=user_info["email"],
name=user_info.get("name", ""),
picture=user_info.get("picture"),
)
request.session["user_id"] = str(user.id)
return RedirectResponse("/dashboard")
GitHub OAuth2 Login
In GitHub → Settings → Developer settings → OAuth Apps, create an app with callback URL http://localhost:8000/auth/github/callback. GitHub uses a slightly different flow — the user info and email are separate endpoints.
GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
GITHUB_USER_URL = "https://api.github.com/user"
GITHUB_EMAIL_URL = "https://api.github.com/user/emails"
GITHUB_REDIRECT_URI = f"{BASE_URL}/auth/github/callback"
@app.get("/auth/github")
async def github_login(request: Request):
state = secrets.token_urlsafe(16)
request.session["oauth_state"] = state
client = AsyncOAuth2Client(
client_id=GITHUB_CLIENT_ID,
client_secret=GITHUB_CLIENT_SECRET,
redirect_uri=GITHUB_REDIRECT_URI,
scope="read:user user:email",
)
url, _ = client.create_authorization_url(GITHUB_AUTH_URL, state=state)
return RedirectResponse(url)
@app.get("/auth/github/callback")
async def github_callback(request: Request, code: str, state: str):
if state != request.session.pop("oauth_state", None):
return {"error": "Invalid state"}
client = AsyncOAuth2Client(
client_id=GITHUB_CLIENT_ID,
client_secret=GITHUB_CLIENT_SECRET,
redirect_uri=GITHUB_REDIRECT_URI,
)
token = await client.fetch_token(GITHUB_TOKEN_URL, code=code)
# GitHub requires Accept header for JSON response
client.headers["Accept"] = "application/json"
user_resp = await client.get(GITHUB_USER_URL)
github_user = user_resp.json()
# Primary email may not be public; fetch from emails endpoint
email = github_user.get("email")
if not email:
emails_resp = await client.get(GITHUB_EMAIL_URL)
emails = emails_resp.json()
primary = next((e for e in emails if e["primary"] and e["verified"]), None)
email = primary["email"] if primary else None
user = await upsert_user(
provider="github",
provider_id=str(github_user["id"]),
email=email,
name=github_user.get("name") or github_user["login"],
picture=github_user.get("avatar_url"),
)
request.session["user_id"] = str(user.id)
return RedirectResponse("/dashboard")
Session Management with Starlette
Starlette's SessionMiddleware stores session data in a signed cookie (HMAC-SHA256). The session dict is available via request.session. For production, consider storing the session server-side in Redis and keeping only a session ID in the cookie.
from fastapi import Depends, HTTPException
async def get_session_user(request: Request) -> dict:
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = await get_user_by_id(user_id)
if not user:
request.session.clear()
raise HTTPException(status_code=401, detail="User not found")
return user
@app.get("/dashboard")
async def dashboard(user=Depends(get_session_user)):
return {"message": f"Welcome {user['name']}", "email": user["email"]}
@app.post("/auth/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/", status_code=302)
User Model and Database Integration
from sqlalchemy import Column, String, DateTime
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import DeclarativeBase
import uuid
from datetime import datetime, timezone
class Base(DeclarativeBase): pass
class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String(255), unique=True, nullable=False)
name = Column(String(255))
picture = Column(String(500))
provider = Column(String(50)) # "google" or "github"
provider_id = Column(String(255)) # provider's user ID
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
last_login = Column(DateTime)
async def upsert_user(provider, provider_id, email, name, picture):
async with AsyncSession(engine) as session:
from sqlalchemy import select
result = await session.execute(
select(User).where(User.provider == provider, User.provider_id == provider_id)
)
user = result.scalar_one_or_none()
if user:
user.last_login = datetime.now(timezone.utc)
user.name = name
user.picture = picture
else:
user = User(
provider=provider, provider_id=provider_id,
email=email, name=name, picture=picture,
last_login=datetime.now(timezone.utc),
)
session.add(user)
await session.commit()
return user
Security Considerations
- Always validate state — the
stateparameter prevents CSRF. Never skip this check. - Use HTTPS in production — OAuth2 tokens in redirect URIs are exposed in server logs over plain HTTP.
- Never expose client secrets — keep them in environment variables, never in code or version control.
- Validate email verified — Google and GitHub both indicate whether the email is verified. Reject unverified emails for sensitive operations.
- Restrict redirect URIs — in both Google and GitHub app settings, allow only your exact callback URL. Never allow wildcards.
Frequently Asked Questions
- Do I need OAuth2 if I already have JWT authentication?
- They serve different purposes. JWT handles your session. OAuth2 is how users prove their identity via a third party (Google/GitHub) instead of creating a password with you. You typically use both: OAuth2 to authenticate the user, then issue a JWT session for subsequent API calls.
- What is PKCE and do I need it?
- PKCE (Proof Key for Code Exchange) is an OAuth2 extension that prevents authorization code interception attacks. It is required for public clients (SPAs, mobile apps) and recommended for all server-side flows in 2026. Authlib supports it via the
code_challenge_method="S256"parameter. - Can I support multiple providers for the same email?
- Yes. Store a
providersjoin table linking user IDs to (provider, provider_id) pairs. On callback, look up by email first — if found, add the new provider as an additional login method for that account.