OAuth Login (Google/GitHub) Setup
The essential playbook for implementing oauth login (google/github) setup in your SaaS.
Configure OAuth login so users can sign in with Google or GitHub without storing another password. This page covers provider app setup, callback routes, token exchange, user creation and linking, session creation, and production issues like redirect URI mismatches, state validation, and HTTPS requirements.
Quick Fix / Quick Setup
Use this baseline first.
# Example environment variables
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
OAUTH_REDIRECT_BASE=https://app.example.com
SESSION_SECRET=replace_me
# Redirect URIs
# Google: https://app.example.com/auth/google/callback
# GitHub: https://app.example.com/auth/github/callback
# Minimal OAuth flow
# 1. GET /auth/google/login -> redirect user to provider auth URL with state
# 2. GET /auth/google/callback -> verify state, exchange code for token
# 3. Fetch provider profile/email
# 4. Find or create local user by verified email/provider id
# 5. Create app session and redirect to dashboardUse HTTPS in production, store provider credentials in environment variables, validate the state parameter, and only trust verified emails from the provider.
What’s happening
- OAuth login redirects the user to Google or GitHub for authentication.
- The provider sends the user back to your callback URL with a temporary authorization code.
- Your backend exchanges that code for an access token.
- Your app uses the token to fetch the user's profile and email.
- You map the provider account to a local user record, then create your normal app session.
- OAuth does not replace your own session handling, authorization rules, or account lifecycle logic.
Step-by-step implementation
1) Define user and identity tables
Use a local users table plus a separate provider identity table.
Example schema:
create table users (
id integer primary key,
email text unique not null,
email_verified boolean not null default false,
created_at datetime not null default current_timestamp
);
create table auth_identities (
id integer primary key,
user_id integer not null,
provider text not null,
provider_user_id text not null,
provider_email text,
avatar_url text,
created_at datetime not null default current_timestamp,
unique(provider, provider_user_id),
foreign key(user_id) references users(id)
);Recommended fields:
users.idusers.emailusers.email_verifiedauth_identities.user_idauth_identities.providerauth_identities.provider_user_id
Do not use username as the durable external identity. Use provider user ID.
2) Create provider apps
Create separate OAuth apps for:
- local development
- staging
- production
Register exact callback URLs.
Examples:
Local:
http://localhost:8000/auth/google/callback
http://localhost:8000/auth/github/callback
Production:
https://app.example.com/auth/google/callback
https://app.example.com/auth/github/callbackCallback URIs must match exactly:
- scheme
- host
- path
- port
- trailing slash behavior
3) Configure environment variables
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
OAUTH_REDIRECT_BASE=https://app.example.com
SESSION_SECRET=replace_meNever commit these into source control.
Validate at runtime:
printenv | grep -E 'GOOGLE|GITHUB|OAUTH|SESSION'
python -c "import os; print(os.getenv('OAUTH_REDIRECT_BASE'))"4) Add login endpoints
Implement:
GET /auth/google/loginGET /auth/github/login
These endpoints should:
- generate a
statevalue - store it server-side or in a signed session cookie
- redirect to the provider authorization URL
Example: Flask implementation
import os
import secrets
from urllib.parse import urlencode
from flask import Flask, session, redirect, request, jsonify
app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]
GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GITHUB_CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
OAUTH_REDIRECT_BASE = os.environ["OAUTH_REDIRECT_BASE"]
@app.get("/auth/google/login")
def google_login():
state = secrets.token_urlsafe(32)
session["oauth_state_google"] = state
params = {
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": f"{OAUTH_REDIRECT_BASE}/auth/google/callback",
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "online",
"prompt": "select_account",
}
return redirect("https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params))
@app.get("/auth/github/login")
def github_login():
state = secrets.token_urlsafe(32)
session["oauth_state_github"] = state
params = {
"client_id": GITHUB_CLIENT_ID,
"redirect_uri": f"{OAUTH_REDIRECT_BASE}/auth/github/callback",
"scope": "read:user user:email",
"state": state,
}
return redirect("https://github.com/login/oauth/authorize?" + urlencode(params))Example: FastAPI implementation
import os
import secrets
from urllib.parse import urlencode
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"])
GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GITHUB_CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
OAUTH_REDIRECT_BASE = os.environ["OAUTH_REDIRECT_BASE"]
@app.get("/auth/google/login")
async def google_login(request: Request):
state = secrets.token_urlsafe(32)
request.session["oauth_state_google"] = state
params = {
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": f"{OAUTH_REDIRECT_BASE}/auth/google/callback",
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "online",
"prompt": "select_account",
}
return RedirectResponse("https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params))
@app.get("/auth/github/login")
async def github_login(request: Request):
state = secrets.token_urlsafe(32)
request.session["oauth_state_github"] = state
params = {
"client_id": GITHUB_CLIENT_ID,
"redirect_uri": f"{OAUTH_REDIRECT_BASE}/auth/github/callback",
"scope": "read:user user:email",
"state": state,
}
return RedirectResponse("https://github.com/login/oauth/authorize?" + urlencode(params))5) Validate state on callback
Implement:
GET /auth/google/callbackGET /auth/github/callback
On callback:
- read
code - read
state - compare with stored state
- reject if missing or mismatched
- clear one-time state after use
Flask callback skeleton
from flask import abort
@app.get("/auth/google/callback")
def google_callback():
expected = session.get("oauth_state_google")
received = request.args.get("state")
code = request.args.get("code")
if not expected or not received or expected != received:
abort(400, "invalid oauth state")
session.pop("oauth_state_google", None)
if not code:
abort(400, "missing authorization code")
return jsonify({"ok": True, "code_received": True})Bind state to the current session and expire it quickly.
6) Exchange code for token
Do the token exchange server-side only.
Google token exchange
import requests
def exchange_google_code(code: str):
resp = requests.post(
"https://oauth2.googleapis.com/token",
data={
"client_id": os.environ["GOOGLE_CLIENT_ID"],
"client_secret": os.environ["GOOGLE_CLIENT_SECRET"],
"code": code,
"grant_type": "authorization_code",
"redirect_uri": f"{os.environ['OAUTH_REDIRECT_BASE']}/auth/google/callback",
},
timeout=10,
)
resp.raise_for_status()
return resp.json()GitHub token exchange
def exchange_github_code(code: str):
resp = requests.post(
"https://github.com/login/oauth/access_token",
headers={"Accept": "application/json"},
data={
"client_id": os.environ["GITHUB_CLIENT_ID"],
"client_secret": os.environ["GITHUB_CLIENT_SECRET"],
"code": code,
"redirect_uri": f"{os.environ['OAUTH_REDIRECT_BASE']}/auth/github/callback",
},
timeout=10,
)
resp.raise_for_status()
return resp.json()Handle non-200 responses explicitly. Log sanitized error details only.
7) Fetch profile and email
Use the provider token to fetch identity data.
Google profile
If using OpenID Connect scopes, id_token may contain verified email claims. You can also fetch userinfo.
def get_google_userinfo(access_token: str):
resp = requests.get(
"https://openidconnect.googleapis.com/v1/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
resp.raise_for_status()
return resp.json()Typical fields:
subemailemail_verifiedpicturename
GitHub profile and emails
def get_github_user(access_token: str):
resp = requests.get(
"https://api.github.com/user",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
resp.raise_for_status()
return resp.json()
def get_github_emails(access_token: str):
resp = requests.get(
"https://api.github.com/user/emails",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
resp.raise_for_status()
return resp.json()GitHub often needs a second request to get email addresses. Request user:email.
Choose a verified primary email:
def choose_verified_github_email(emails):
for e in emails:
if e.get("verified") and e.get("primary"):
return e["email"]
for e in emails:
if e.get("verified"):
return e["email"]
return NoneIf no verified email exists:
- do not auto-create or auto-link the account
- require manual email collection and verification
8) Find or create the local user
Recommended identity resolution flow:
- Look up
auth_identities(provider, provider_user_id) - If found, sign in that user
- If not found, find local user by verified email
- Link only if your policy allows it
- Otherwise create a new local user
- Insert provider identity row
Example logic:
def resolve_user_from_oauth(db, provider, provider_user_id, email, email_verified, avatar_url=None):
identity = db.execute(
"select user_id from auth_identities where provider = ? and provider_user_id = ?",
(provider, provider_user_id)
).fetchone()
if identity:
return identity[0]
if not email or not email_verified:
raise ValueError("verified email required")
existing_user = db.execute(
"select id from users where email = ?",
(email,)
).fetchone()
if existing_user:
user_id = existing_user[0]
else:
cur = db.execute(
"insert into users (email, email_verified) values (?, ?)",
(email, True)
)
user_id = cur.lastrowid
db.execute(
"""insert into auth_identities
(user_id, provider, provider_user_id, provider_email, avatar_url)
values (?, ?, ?, ?, ?)""",
(user_id, provider, provider_user_id, email, avatar_url)
)
db.commit()
return user_idImportant:
- use a unique constraint on
(provider, provider_user_id) - define explicit account-linking rules
- do not create duplicate users when password login and OAuth both exist
For account linking policy, this is safe:
- link by verified email only
- or require the user to authenticate to the existing account before linking a new provider
9) Create your normal app session
After resolving the local user, create your own session.
Example Flask session:
session["user_id"] = user_id
session["authenticated"] = TrueDo not keep users authenticated only because you have a provider access token.
If your app uses JWT:
- complete OAuth on the backend first
- then issue your app JWT normally
See also:
10) Redirect and clear one-time values
After successful login:
- clear one-time state
- redirect to dashboard or onboarding
- never expose provider secrets or raw tokens in redirects
Example:
return redirect("/dashboard")11) Add logout logic
Logging out from your app should destroy your app session.
Do not rely on Google or GitHub logout to log out the user from your SaaS.
Related guide:
Provider-specific notes
- Google usually returns verified email status and is simpler for email-first sign-in flows.
- GitHub may require
user:emailand a follow-up API request to fetch verified emails. - GitHub usernames are not stable identifiers for authentication; use provider user ID.
- Google and GitHub callback URIs must match exactly, including scheme, host, path, and trailing slash behavior.
- If you support both password login and OAuth, define explicit account-linking rules to avoid duplicate accounts.
Add a sequence diagram showing browser -> provider -> callback -> token exchange -> profile fetch -> local session creation.
Common causes
- Redirect URI mismatch between provider settings and your callback route.
- Using
httpin production or generating the wrong scheme behind a reverse proxy. - State parameter missing, expired, or not matching the stored value.
- GitHub email scope missing, so no usable email is returned.
- Attempting to auto-link accounts without a clear verified-email policy.
- Creating duplicate users because provider identity and local email linking are handled separately.
- Session cookie not persisting due to bad
SameSite,Secure, domain, or proxy settings. - Provider client ID and secret loaded from the wrong environment.
- Relying on provider logout instead of clearing the local app session.
- Callback route blocked by middleware, CSRF logic, or incorrect route registration.
Debugging tips
Check environment and callback routes first.
printenv | grep -E 'GOOGLE|GITHUB|OAUTH|SESSION'
curl -I https://app.example.com/auth/google/callback
curl -I https://app.example.com/auth/github/callback
curl -v https://app.example.com/auth/google/login
curl -v https://app.example.com/auth/github/login
grep -R "callback" -n .
grep -R "X-Forwarded-Proto\|ProxyFix\|forwarded" -n .
python -c "import os; print(os.getenv('OAUTH_REDIRECT_BASE'))"
sqlite3 app.db 'select id,email,email_verified from users;'
sqlite3 app.db 'select user_id,provider,provider_user_id from auth_identities;'Also verify proxy and cookie behavior.
Flask behind proxy
If your app is behind Nginx, a platform router, or a load balancer, trust forwarded headers correctly.
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)Secure cookie config
Flask:
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)FastAPI / Starlette session middleware example should be paired with secure deployment and HTTPS termination.
Callback mismatch symptoms
Typical provider errors:
redirect_uri_mismatchbad verification codestate missing or invalid
Check:
- exact redirect URI in provider console
- generated redirect URI in your app
- HTTPS scheme after proxy forwarding
- environment-specific client ID and secret pairing
For broader auth failures, see:
Checklist
- ✓ Google and GitHub apps created with correct redirect URIs.
- ✓ Client ID and secret stored in environment variables.
- ✓ State parameter generated, stored, and validated.
- ✓ Code exchange implemented server-side.
- ✓ Verified email handling defined.
- ✓ Local user and provider identity linkage implemented.
- ✓ Session creation after OAuth login implemented.
- ✓ Logout clears local session.
- ✓ Production redirect URI uses HTTPS.
- ✓ Provider errors and callback failures logged safely.
- ✓ Reverse proxy or forwarded headers configured so redirects use
https. - ✓ Session cookie flags set correctly for production.
For launch review, also check:
Related guides
- Implement User Authentication (Login/Register)
- Session Management vs JWT (When to Use What)
- Handling User Logout Correctly
- Common Auth Bugs and Fixes
- SaaS Production Checklist
FAQ
Should I create separate OAuth apps for development and production?
Yes. It avoids redirect URI conflicts and keeps secrets isolated by environment.
What is the safest way to link an OAuth login to an existing account?
Link by verified email only, or require the user to authenticate to the existing account before linking a new provider.
Why does GitHub login work but no email is available?
GitHub may not expose email in the base profile response. Request the user:email scope and fetch emails from the email endpoint.
Do I need sessions if I already use OAuth?
Yes. OAuth authenticates with the provider, then your app still needs its own session or JWT strategy.
What should I store from the provider?
Store provider name, provider user ID, and any profile fields you need. Avoid storing tokens unless you need provider API access later.
Final takeaway
OAuth setup is mostly provider configuration plus a reliable callback flow.
The production-critical parts are:
- exact redirect URIs
- state validation
- verified email handling
- correct local account linking
- proper session creation after login
Treat Google and GitHub as identity providers. Your app should still own session management, authorization, logout behavior, and account lifecycle rules.