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.

bash
# 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 dashboard

Use 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:

sql
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.id
  • users.email
  • users.email_verified
  • auth_identities.user_id
  • auth_identities.provider
  • auth_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:

text
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/callback

Callback URIs must match exactly:

  • scheme
  • host
  • path
  • port
  • trailing slash behavior

3) Configure environment variables

bash
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

Never commit these into source control.

Validate at runtime:

bash
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/login
  • GET /auth/github/login

These endpoints should:

  1. generate a state value
  2. store it server-side or in a signed session cookie
  3. redirect to the provider authorization URL

Example: Flask implementation

python
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

python
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/callback
  • GET /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

python
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

python
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

python
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.

python
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:

  • sub
  • email
  • email_verified
  • picture
  • name

GitHub profile and emails

python
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:

python
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 None

If 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:

  1. Look up auth_identities(provider, provider_user_id)
  2. If found, sign in that user
  3. If not found, find local user by verified email
  4. Link only if your policy allows it
  5. Otherwise create a new local user
  6. Insert provider identity row

Example logic:

python
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_id

Important:

  • 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:

python
session["user_id"] = user_id
session["authenticated"] = True

Do 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:

python
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:email and 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.
browser
provider
callback
token exchange
profile fetch
local session creation

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 http in 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.

bash
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.

python
from werkzeug.middleware.proxy_fix import ProxyFix

app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)

Secure cookie config

Flask:

python
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_mismatch
  • bad verification code
  • state 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

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.