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.

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 state parameter 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 providers join 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.