7 min read

GitHub OAuth Login with FastAPI - Complete Tutorial with Code Examples (2025)

Learn how to implement GitHub OAuth login in FastAPI. Step-by-step tutorial with code examples covering OAuth setup, authentication flow, user data handling, and security best practices.

This tutorial is direct and practical. I focus on code that works, not theory.

You can copy the examples and adapt them to your needs.

Login with GitHub

Introduction

Adding "Login with GitHub" to your FastAPI app lets users sign in without creating a password.

This FastAPI GitHub OAuth tutorial shows you how to implement GitHub authentication with complete code examples.

What you'll learn:

  • Set up GitHub OAuth App
  • Implement OAuth 2.0 flow in FastAPI
  • Handle user authentication and sessions
  • Store user data securely
  • Production deployment tips

Prerequisites: Basic Python and FastAPI knowledge, GitHub account.

Why GitHub OAuth?

GitHub OAuth is perfect for developer tools and SaaS apps because:

  • Your users already have GitHub accounts
  • No password management needed
  • Users trust GitHub's security
  • Free to implement
  • Get access to user's public info (name, email, avatar)

Popular apps using GitHub OAuth: Vercel, Netlify, Railway, Render.

How OAuth Works

Here's the flow in simple terms:

  1. User clicks "Login with GitHub" on your site
  2. You redirect them to GitHub's authorization page
  3. User approves access to their GitHub account
  4. GitHub redirects back to your site with a code
  5. You exchange the code for an access token
  6. You use the token to fetch user data from GitHub
  7. You create a session and log the user in

This takes about 2-3 seconds for the user.

Initial Setup

Install Dependencies

pip install fastapi uvicorn httpx pydantic-settings python-jose[cryptography]

What each package does:

  • httpx: Make HTTP requests to GitHub API
  • pydantic-settings: Manage environment variables
  • python-jose: Create secure JWT tokens for sessions

Create GitHub OAuth App

Go to GitHub Developer Settings and create a new OAuth App:

  1. Click "New OAuth App"
  2. Application name: YourApp (Development)
  3. Homepage URL: http://localhost:8000
  4. Authorization callback URL: http://localhost:8000/auth/github/callback
  5. Enable Device Flow: Leave unchecked (not needed for web apps)
  6. Click "Register application"

Save these values:

  • Client ID: Ov23li... (starts with "Ov", safe to expose publicly)
  • Client Secret: Click "Generate a new client secret" and copy it immediately (keep this secret!)

Note: This is your development OAuth app. You'll create a separate production OAuth app when deploying (see "Production Deployment" section).

Environment Setup

Create a .env file:

GITHUB_CLIENT_ID=your_localhost_client_id_here
GITHUB_CLIENT_SECRET=your_localhost_client_secret_here
JWT_SECRET_KEY=your_random_secret_key_here_min_32_chars
DOMAIN=http://localhost:8000

Generate a secure JWT secret:

python -c "import secrets; print(secrets.token_urlsafe(32))"

Basic FastAPI Setup

Create main.py:

from fastapi import FastAPI, Request, Depends, HTTPException
from pydantic_settings import BaseSettings, SettingsConfigDict
import httpx
from jose import jwt, JWTError
from datetime import datetime, timedelta

class Settings(BaseSettings):
    github_client_id: str
    github_client_secret: str
    jwt_secret_key: str
    domain: str = "http://localhost:8000"

    model_config = SettingsConfigDict(env_file=".env")

settings = Settings()

app = FastAPI()

@app.get("/")
def home():
    return {"message": "FastAPI + GitHub OAuth"}

Run it:

uvicorn main:app --reload

Implement OAuth Flow

Step 1: Login Endpoint

This redirects users to GitHub:

from fastapi.responses import RedirectResponse

@app.get("/login/github")
def login_github():
    github_auth_url = (
        f"https://github.com/login/oauth/authorize"
        f"?client_id={settings.github_client_id}"
        f"&redirect_uri={settings.domain}/auth/github/callback"
        f"&scope=read:user user:email"
    )
    return RedirectResponse(github_auth_url)

What's happening:

  • client_id: Identifies your app to GitHub
  • redirect_uri: Where GitHub sends users after approval
  • scope: What data you want access to
    • read:user: Get user profile info
    • user:email: Get user email addresses

Additional scopes you might need:

  • repo: Access to repositories (if building a Git tool)
  • read:org: Organization membership

See GitHub OAuth scopes for all options.

Step 2: Callback Endpoint

GitHub redirects here with a code:

@app.get("/auth/github/callback")
async def github_callback(code: str):
    access_token = await _exchange_code_for_access_token(code)
    github_user_info = await _get_user_info_from_github(access_token)

    # TODO: Save user to database here

    # Create JWT token for session
    jwt_token = create_jwt_token(user)

    # Redirect to dashboard with token
    response = RedirectResponse(url="/dashboard")
    response.set_cookie(
        key="access_token",
        value=jwt_token,
        httponly=True,
        secure=True,  # Use in production with HTTPS
        samesite="lax",
        max_age=60 * 60 * 24 * 7  # 7 days
    )

    return response


async def _exchange_code_for_access_token(code: str) -> str:
    # Exchange code for access token
    async with httpx.AsyncClient() as client:
        token_response = await client.post(
            "https://github.com/login/oauth/access_token",
            data={
                "client_id": settings.github_client_id,
                "client_secret": settings.github_client_secret,
                "code": code,
            },
            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 get access token")

    return access_token


async def _get_user_info_from_github(access_token: str) -> dict:
    async with httpx.AsyncClient() as client:
        user_response = await client.get(
            "https://api.github.com/user",
            headers={"Authorization": f"Bearer {access_token}"},
        )

        email_response = await client.get(
            "https://api.github.com/user/emails",
            headers={"Authorization": f"Bearer {access_token}"},
        )

    user_data = user_response.json()
    emails = email_response.json()

    primary_email = next(
        (email["email"] for email in emails if email["primary"]),
        user_data.get("email")  # Fallback to public email
    )

    return {
        "github_id": user_data["id"],
        "username": user_data["login"],
        "email": primary_email,
        "name": user_data.get("name"),
        "avatar_url": user_data.get("avatar_url"),
    }

What's happening:

  1. Exchange authorization code for access token
  2. Use access token to fetch user data from GitHub API
  3. Get user's primary email (GitHub emails can be private)
  4. Create a session token (JWT)
  5. Set cookie and redirect to dashboard

Note on emails: Some users hide their email. Always check user:email scope and handle missing emails gracefully.

Step 3: JWT Token Creation

Create session tokens:

def create_jwt_token(user: dict) -> str:
    payload = {
        "user_id": user["github_id"],
        "username": user["username"],
        "email": user["email"],
        "exp": datetime.utcnow() + timedelta(days=7)
    }
    token = jwt.encode(
        payload,
        settings.jwt_secret_key,
        algorithm="HS256"
    )
    return token

def decode_jwt_token(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            settings.jwt_secret_key,
            algorithms=["HS256"]
        )
        return payload
    except JWTError:
        return None

Why JWT?

  • Stateless (no database lookup on every request)
  • Contains user info
  • Cryptographically signed (tamper-proof)
  • Has expiration built-in

Protect Routes

Create a dependency to get the current user:

from fastapi import Cookie

def get_current_user(access_token: str = Cookie(None)):
    if not access_token:
        raise HTTPException(
            status_code=401,
            detail="Not authenticated"
        )

    user_data = decode_jwt_token(access_token)
    if not user_data:
        raise HTTPException(
            status_code=401,
            detail="Invalid or expired token"
        )

    return user_data

@app.get("/dashboard")
def dashboard(current_user: dict = Depends(get_current_user)):
    return {
        "message": f"Welcome {current_user['username']}!",
        "user": current_user
    }

@app.get("/api/me")
def get_me(current_user: dict = Depends(get_current_user)):
    return current_user

Now any route with Depends(get_current_user) requires authentication.

Add Database Storage

You should save users to a database. Here's an example with SQLAlchemy:

from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session

# Database setup
SQLALCHEMY_DATABASE_URL = "sqlite:///./app.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

# User model
class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    github_id = Column(Integer, unique=True, index=True)
    username = Column(String, unique=True)
    email = Column(String, unique=True, nullable=True)
    name = Column(String, nullable=True)
    avatar_url = Column(String, nullable=True)

# Create tables
Base.metadata.create_all(bind=engine)

# Database dependency
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Update callback to save user:

from fastapi import Depends

@app.get("/auth/github/callback")
async def github_callback(code: str, db: Session = Depends(get_db)):
    # ... (previous code to get user data from GitHub)

    # Check if user exists
    db_user = db.query(User).filter(User.github_id == user_data["id"]).first()

    if db_user:
        # Update existing user
        db_user.username = user_data["login"]
        db_user.email = primary_email
        db_user.name = user_data.get("name")
        db_user.avatar_url = user_data.get("avatar_url")
    else:
        # Create new user
        db_user = User(
            github_id=user_data["id"],
            username=user_data["login"],
            email=primary_email,
            name=user_data.get("name"),
            avatar_url=user_data.get("avatar_url"),
        )
        db.add(db_user)

    db.commit()
    db.refresh(db_user)

    # Create JWT with database user ID
    jwt_token = create_jwt_token({
        "user_id": db_user.github_id,
        "username": db_user.username,
        "email": db_user.email,
    })

    # ... (rest of the code)

Frontend Integration

Simple HTML example:

<!DOCTYPE html>
<html>
<head>
    <title>Login with GitHub</title>
    <style>
        .github-button {
            display: inline-flex;
            align-items: center;
            gap: 10px;
            padding: 12px 24px;
            background: #24292e;
            color: white;
            text-decoration: none;
            border-radius: 6px;
            font-size: 16px;
            font-weight: 600;
        }
        .github-button:hover {
            background: #1b1f23;
        }
    </style>
</head>
<body>
    <h1>Welcome to My App</h1>
    <a href="/login/github" class="github-button">
        <svg height="20" width="20" viewBox="0 0 16 16" fill="currentColor">
            <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
        </svg>
        Login with GitHub
    </a>
</body>
</html>

With JavaScript Frontend

For React/Vue/Svelte apps:

// Trigger login
function loginWithGitHub() {
    window.location.href = 'http://localhost:8000/login/github';
}

// Check if user is logged in
async function getCurrentUser() {
    const response = await fetch('http://localhost:8000/api/me', {
        credentials: 'include'  // Important: include cookies
    });

    if (response.ok) {
        const user = await response.json();
        console.log('Logged in as:', user);
        return user;
    } else {
        console.log('Not logged in');
        return null;
    }
}

// Logout
async function logout() {
    const response = await fetch('http://localhost:8000/logout', {
        method: 'POST',
        credentials: 'include'
    });

    if (response.ok) {
        window.location.href = '/';
    }
}

Add logout endpoint:

@app.post("/logout")
def logout():
    response = RedirectResponse(url="/")
    response.delete_cookie(key="access_token")
    return response

Security Best Practices

1. Use HTTPS in Production

# In production only
response.set_cookie(
    key="access_token",
    value=jwt_token,
    httponly=True,
    secure=True,  # Requires HTTPS
    samesite="strict",  # Prevents CSRF
    max_age=60 * 60 * 24 * 7
)

2. Validate Redirect URI

GitHub checks this automatically, but verify in your code:

ALLOWED_CALLBACK_URLS = [
    "http://localhost:8000/auth/github/callback",
    "https://yourdomain.com/auth/github/callback"
]

@app.get("/auth/github/callback")
async def github_callback(code: str, request: Request):
    callback_url = str(request.url)
    if not any(callback_url.startswith(allowed) for allowed in ALLOWED_CALLBACK_URLS):
        raise HTTPException(status_code=400, detail="Invalid callback URL")

    # ... rest of code

3. Add State Parameter (CSRF Protection)

Prevent CSRF attacks:

import secrets
from typing import Dict

# In-memory store (use Redis in production)
oauth_states: Dict[str, bool] = {}

@app.get("/login/github")
def login_github():
    # Generate random state
    state = secrets.token_urlsafe(32)
    oauth_states[state] = True

    github_auth_url = (
        f"https://github.com/login/oauth/authorize"
        f"?client_id={settings.github_client_id}"
        f"&redirect_uri={settings.domain}/auth/github/callback"
        f"&scope=read:user user:email"
        f"&state={state}"
    )
    return RedirectResponse(github_auth_url)

@app.get("/auth/github/callback")
async def github_callback(code: str, state: str):
    # Verify state
    if state not in oauth_states:
        raise HTTPException(status_code=400, detail="Invalid state parameter")

    # Remove used state
    del oauth_states[state]

    # ... rest of code

Important: In production, use Redis or a database to store states instead of in-memory dict.

Production Deployment

Create Production OAuth App

Important: Do NOT update your development OAuth app. Create a new separate OAuth app for production.

Go to GitHub Developer Settings and create a new OAuth App:

  1. Click "New OAuth App"
  2. Application name: YourApp (Production)
  3. Homepage URL: https://yourdomain.com
  4. Authorization callback URL: https://yourdomain.com/auth/github/callback
  5. Click "Register application"
  6. Copy the new production Client ID and Client Secret

Now you have two OAuth apps:

  • Development: Uses http://localhost:8000
  • Production: Uses https://yourdomain.com

Environment Variables

Update your production .env with the production OAuth app credentials:

GITHUB_CLIENT_ID=your_production_client_id_here
GITHUB_CLIENT_SECRET=your_production_client_secret_here
JWT_SECRET_KEY=use_a_long_random_string_min_32_chars
DOMAIN=https://yourdomain.com

Never commit .env to Git! Add it to .gitignore:

.env
__pycache__/
*.pyc

Keep your development .env file locally with the localhost OAuth credentials for testing.

CORS for Frontend

If your frontend is on a different domain:

pip install fastapi[all]
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourfrontend.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Common Issues

Issue: "Email is null"

Solution: User's email is private. Either:

  1. Make email optional in your app
  2. Ask users to make their GitHub email public
  3. Request the user:email scope (already in our example)

Issue: "Invalid redirect URI"

Solution:

  • Verify callback URL matches exactly in GitHub settings
  • Include protocol (http:// or https://)
  • No trailing slashes unless your endpoint has one

Issue: "Access token not working"

Solution:

  • Check you're using Bearer token in Authorization header
  • Verify scopes are correct
  • Token might be expired (they last forever by default for OAuth apps)

Issue: "Cookie not being set"

Solution:

  • Verify httponly=True is set
  • In production, use secure=True with HTTPS
  • Check samesite attribute matches your setup
  • Ensure credentials: 'include' in frontend fetch

Issue: "CORS errors"

Solution:

  • Add CORS middleware (see Production section)
  • Set allow_credentials=True
  • Specify exact origin, not *

Testing

Manual Testing

  1. Start your app:
uvicorn main:app --reload
  1. Open http://localhost:8000 in your browser

  2. Click "Login with GitHub"

  3. You'll be redirected to GitHub's authorization page

  4. Click "Authorize" (you may need to enter your GitHub password)

  5. You'll be redirected back to http://localhost:8000/auth/github/callback

  6. If successful, you'll be redirected to /dashboard with a session cookie

  7. Check the cookie in DevTools:

    • Open DevTools (F12)
    • Go to Application > Cookies
    • Look for access_token cookie

Test Protected Routes

Test with browser DevTools Console:

// Test if logged in
fetch('/api/me', { credentials: 'include' })
  .then(r => r.json())
  .then(console.log);

Or use curl:

# Get your cookie from browser DevTools (Application > Cookies)
export TOKEN="your_jwt_token_from_cookie"

# Test authenticated endpoint
curl -X GET http://localhost:8000/api/me \
  -H "Cookie: access_token=$TOKEN"

Automated Testing

For unit tests, mock the GitHub OAuth flow:

from fastapi.testclient import TestClient

client = TestClient(app)

def test_login_redirect():
    """Test that /login/github redirects to GitHub"""
    response = client.get("/login/github", follow_redirects=False)
    assert response.status_code == 307
    assert "github.com" in response.headers["location"]
    assert "client_id" in response.headers["location"]

def test_protected_route_unauthorized():
    """Test that protected routes require authentication"""
    response = client.get("/dashboard")
    assert response.status_code == 401

def test_protected_route_authorized():
    """Test that valid JWT grants access"""
    # Create test JWT
    token = create_jwt_token({
        "user_id": 12345,
        "username": "testuser",
        "email": "test@example.com"
    })

    response = client.get(
        "/dashboard",
        cookies={"access_token": token}
    )
    assert response.status_code == 200
    assert "testuser" in response.json()["message"]

def test_callback_without_code():
    """Test callback fails without authorization code"""
    response = client.get("/auth/github/callback")
    assert response.status_code == 422  # Missing required parameter

Note: Testing the full OAuth callback requires mocking HTTP requests to GitHub's API. Use pytest-mock or respx for this:

pip install pytest respx
import respx
from httpx import Response

@respx.mock
async def test_github_callback_flow():
    """Test complete OAuth callback with mocked GitHub API"""

    # Mock token exchange
    respx.post("https://github.com/login/oauth/access_token").mock(
        return_value=Response(200, json={"access_token": "gho_test123"})
    )

    # Mock user endpoint
    respx.get("https://api.github.com/user").mock(
        return_value=Response(200, json={
            "id": 12345,
            "login": "testuser",
            "name": "Test User",
            "avatar_url": "https://github.com/avatar.jpg"
        })
    )

    # Mock emails endpoint
    respx.get("https://api.github.com/user/emails").mock(
        return_value=Response(200, json=[
            {"email": "test@example.com", "primary": True, "verified": True}
        ])
    )

    # Test callback
    response = client.get("/auth/github/callback?code=test_code")
    assert response.status_code == 307  # Redirect to dashboard
    assert "access_token" in response.cookies

Troubleshooting

"Invalid redirect URI" error:

  • Verify your OAuth app callback URL is exactly http://localhost:8000/auth/github/callback
  • Check your .env has DOMAIN=http://localhost:8000 (no trailing slash)
  • Make sure you're using the development OAuth app credentials, not production

Cookie not being set:

  • Check browser console for errors
  • Set secure=False for local development (change line 192 in callback endpoint)
  • Use http://localhost:8000, not 127.0.0.1:8000 (GitHub requires exact match)

"Not authenticated" error:

  • Clear browser cookies and try again
  • Check JWT_SECRET_KEY is set in .env
  • Verify token hasn't expired (check exp claim in jwt.io)

Next Steps

You now have GitHub OAuth working in your FastAPI app. What's next?

Enhance authentication:

  • Add email/password login as alternative
  • Support multiple OAuth providers (Google, Twitter, LinkedIn)
  • Implement refresh tokens for longer sessions
  • Add two-factor authentication (2FA)

Improve user experience:

  • Show user profile with GitHub data
  • Link to user's GitHub profile
  • Display their repositories
  • Show contribution stats using GitHub API

Production improvements:

  • Add Redis for session storage
  • Implement token refresh logic
  • Add audit logging (who logged in when)
  • Set up monitoring for failed logins

Skip the Setup

Building authentication from scratch takes time. This is why I've packed all of my experience into FastSaaS, my FastAPI SaaS template. It includes:

  • ✅ GitHub OAuth (and Google OAuth)
  • ✅ Email/password authentication
  • ✅ User management and profiles
  • ✅ Team/organization support
  • ✅ Stripe subscription billing
  • ✅ Admin dashboard
  • ✅ Production-ready deployment

Questions? Email me at salim@fast-saas.com, I'll be happy to help!

Salim Aboubacar

Written by Salim Aboubacar

Building FastSaaS to help developers ship faster.

Ready to build your SaaS?

Get started with FastSaaS and ship your product in days, not months.

Get FastSaaS