Skip to main content

OAuth2 with Google and GitHub Login

Letting users "Sign in with Google" or "Sign in with GitHub" reduces friction, eliminates password management, and adds trust. You implement this using OAuth2's authorization code flow: redirect to Google/GitHub, they authenticate the user, you receive an authorization code, exchange it for tokens, fetch user data, and auto-create an account. This pattern is used by thousands of SaaS apps.

Registering Your Application

Google

  1. Go to Google Cloud Console.
  2. Create a new project.
  3. Enable Google+ API.
  4. Create OAuth 2.0 credentials (type: Web application).
  5. Set redirect URI: http://localhost:8000/auth/callback/google (or your production domain).
  6. Note the Client ID and Client Secret.

GitHub

  1. Go to GitHub Settings › Developer Settings › OAuth Apps.
  2. Create a new OAuth application.
  3. Set redirect URI: http://localhost:8000/auth/callback/github.
  4. Note the Client ID and Client Secret.

Store secrets in environment variables:

GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxx
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx

OAuth2 Provider Configuration

Define reusable OAuth2 provider configs:

# oauth2_providers.py
from dataclasses import dataclass

@dataclass
class OAuth2Provider:
name: str
auth_url: str
token_url: str
userinfo_url: str
client_id: str
client_secret: str
redirect_uri: str
scopes: list[str]

GOOGLE = OAuth2Provider(
name="google",
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
client_id=os.getenv("GOOGLE_CLIENT_ID"),
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
redirect_uri="http://localhost:8000/auth/callback/google",
scopes=["openid", "email", "profile"]
)

GITHUB = OAuth2Provider(
name="github",
auth_url="https://github.com/login/oauth/authorize",
token_url="https://github.com/login/oauth/access_token",
userinfo_url="https://api.github.com/user",
client_id=os.getenv("GITHUB_CLIENT_ID"),
client_secret=os.getenv("GITHUB_CLIENT_SECRET"),
redirect_uri="http://localhost:8000/auth/callback/github",
scopes=["user:email"]
)

PROVIDERS = {"google": GOOGLE, "github": GITHUB}

Step 1: Redirect to Provider

Create a login endpoint that redirects to the OAuth2 provider:

# main.py
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from urllib.parse import urlencode
import secrets
import os

app = FastAPI()

@app.get("/auth/login/{provider}")
async def oauth2_login(provider: str):
"""Redirect to OAuth2 provider (google or github)."""
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail="Unknown provider")

oauth_provider = PROVIDERS[provider]

# Generate state token for CSRF protection
state = secrets.token_urlsafe(16)
# In production, store state in Redis/database with TTL
request.session["oauth_state"] = state

params = {
"client_id": oauth_provider.client_id,
"redirect_uri": oauth_provider.redirect_uri,
"response_type": "code",
"scope": " ".join(oauth_provider.scopes),
"state": state
}

auth_url = f"{oauth_provider.auth_url}?{urlencode(params)}"
return RedirectResponse(url=auth_url)

Step 2: Handle Callback and Exchange Code

Process the authorization code and fetch user info:

# main.py (continued)
import httpx
from sqlalchemy.orm import Session
from models import User

@app.get("/auth/callback/{provider}")
async def oauth2_callback(
provider: str,
code: str,
state: str,
db: Session = Depends(get_db)
):
"""Exchange authorization code for access token and user info."""
# Validate state token
if state != request.session.get("oauth_state"):
raise HTTPException(status_code=400, detail="CSRF validation failed")

if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail="Unknown provider")

oauth_provider = PROVIDERS[provider]

# Exchange code for access token
async with httpx.AsyncClient() as client:
token_response = await client.post(
oauth_provider.token_url,
data={
"client_id": oauth_provider.client_id,
"client_secret": oauth_provider.client_secret,
"code": code,
"redirect_uri": oauth_provider.redirect_uri,
"grant_type": "authorization_code"
},
headers={"Accept": "application/json"}
)

if token_response.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to get access token")

tokens = token_response.json()
access_token = tokens["access_token"]

# Fetch user info
async with httpx.AsyncClient() as client:
user_response = await client.get(
oauth_provider.userinfo_url,
headers={"Authorization": f"Bearer {access_token}"}
)

if user_response.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to get user info")

user_data = user_response.json()

# Extract common fields (varies by provider)
if provider == "google":
email = user_data.get("email")
name = user_data.get("name")
avatar_url = user_data.get("picture")
elif provider == "github":
email = user_data.get("email")
name = user_data.get("name")
avatar_url = user_data.get("avatar_url")

# Auto-create or update user
user = db.query(User).filter(User.email == email).first()

if not user:
# Create new user from OAuth2 data
user = User(
username=name or email.split("@")[0],
email=email,
avatar_url=avatar_url,
oauth_provider=provider,
oauth_id=user_data.get("id")
)
db.add(user)
db.commit()
else:
# Update existing user
user.avatar_url = avatar_url
user.last_login = datetime.utcnow()
db.commit()

# Issue your app's JWT token
internal_token = create_access_token(data={"sub": user.username})

# Redirect to frontend with token (or set httpOnly cookie)
return RedirectResponse(
url=f"http://localhost:3000/auth-success?token={internal_token}"
)

User Model with OAuth2 Fields

Add OAuth2 fields to your User model:

# models.py
from sqlalchemy import Column, String, DateTime
from datetime import datetime

class User(Base):
__tablename__ = "users"

id = Column(String, primary_key=True)
username = Column(String, unique=True)
email = Column(String, unique=True)
hashed_password = Column(String, nullable=True) # Null for OAuth2 users

# OAuth2 fields
oauth_provider = Column(String, nullable=True) # "google" or "github"
oauth_id = Column(String, nullable=True) # Provider's user ID
avatar_url = Column(String, nullable=True)

created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True)

Handling Multi-Provider Accounts

Allow users to link multiple providers to one account:

# models.py
class OAuthAccount(Base):
__tablename__ = "oauth_accounts"

id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), index=True)
provider = Column(String) # "google" or "github"
provider_user_id = Column(String)
provider_email = Column(String)
connected_at = Column(DateTime, default=datetime.utcnow)

__table_args__ = (UniqueConstraint("provider", "provider_user_id"),)

@app.get("/auth/callback/{provider}")
async def oauth2_callback(provider: str, code: str, state: str, db: Session = Depends(get_db)):
"""..."""
# ... fetch user_data from provider ...

# Check if OAuth account exists
oauth_account = db.query(OAuthAccount).filter(
OAuthAccount.provider == provider,
OAuthAccount.provider_user_id == str(user_data.get("id"))
).first()

if oauth_account:
# Link to existing account
user = oauth_account.user
else:
# Find or create user by email
user = db.query(User).filter(User.email == email).first()
if not user:
user = User(
username=name or email.split("@")[0],
email=email,
avatar_url=avatar_url
)
db.add(user)
db.commit()

# Create OAuth account link
oauth_account = OAuthAccount(
user_id=user.id,
provider=provider,
provider_user_id=str(user_data.get("id")),
provider_email=email
)
db.add(oauth_account)
db.commit()

# Issue internal JWT
token = create_access_token(data={"sub": user.username})
return RedirectResponse(url=f"http://localhost:3000/auth-success?token={token}")

Error Handling and Edge Cases

Handle common OAuth2 errors:

@app.get("/auth/callback/{provider}")
async def oauth2_callback(provider: str, code: str | None = None, error: str | None = None, db: Session = Depends(get_db)):
"""Handle OAuth2 callback with error handling."""

# Handle OAuth2 errors
if error:
error_description = request.query_params.get("error_description", "")
return RedirectResponse(
url=f"http://localhost:3000/auth-error?error={error}&description={error_description}"
)

if not code:
raise HTTPException(status_code=400, detail="Missing authorization code")

try:
oauth_provider = PROVIDERS[provider]

# Exchange code for token
async with httpx.AsyncClient() as client:
token_response = await client.post(
oauth_provider.token_url,
timeout=10, # Timeout after 10s
data={...}
)

if token_response.status_code != 200:
error_data = token_response.json()
raise HTTPException(
status_code=400,
detail=f"Token exchange failed: {error_data.get('error_description', 'Unknown error')}"
)

# ... continue with user creation ...

except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="OAuth2 provider timeout")
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=f"Network error: {str(e)}")

Frontend Integration

In your frontend, redirect to the OAuth2 login:

// frontend.js
function handleGoogleLogin() {
window.location.href = "/auth/login/google";
}

function handleGitHubLogin() {
window.location.href = "/auth/login/github";
}

// After callback redirect, extract token and store it
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("token");
if (token) {
localStorage.setItem("access_token", token);
window.location.href = "/dashboard";
}

Key Takeaways

  • OAuth2 social login delegates authentication to Google/GitHub, eliminating password management.
  • Exchange authorization codes for access tokens in your backend (never expose client secret).
  • Auto-create or update user accounts from OAuth2 profile data.
  • Use state tokens to prevent CSRF attacks during the callback.
  • Allow multiple OAuth2 providers per user account for flexibility.

Frequently Asked Questions

What if the user's email is not public on GitHub?

GitHub's email endpoint returns null if the user's email is private. Prompt the user to add an email or use their username + provider ID as a fallback identifier.

Should I store the provider's access token?

Store it if you need ongoing access to the user's data (e.g., fetch their GitHub repos). Otherwise, discard it. If you store it, use encryption and refresh regularly.

Can I use the provider's token to access their API?

Yes. The access token from Google or GitHub can call their APIs on the user's behalf. Example: fetch user's GitHub repos, Google Drive files. Store the token securely if needed.

What if a user tries to sign up with Google but that email is already linked to another account?

Merge accounts or prompt the user to log in with their existing account first. This prevents duplicate accounts and confusion.

Further Reading