FastAPI

FastAPI OAuth2 Social Login with Google (Sign In with Google)

Thirdy Gayares
16 min read

šŸŽÆ What You Will Learn

"Sign in with Google" means you never store a password, never run a password-reset flow, and let Google handle 2FA and account security. We'll wire the full OAuth2 flow in FastAPI with Authlib — and end by issuing your own JWT so your app stays in control of sessions.

  • The OAuth2 authorization code flow, end to end
  • Creating Google OAuth credentials the right way
  • The login redirect and callback with Authlib
  • Upserting the user (find-or-create by verified email)
  • Issuing your own JWT — don't use Google's token as your session
  • The security must-dos: state, verified email, redirect URI matching

Prerequisites: a FastAPI app with a database (how-to-connect-fastapi-to-postgres) and a grasp of JWTs (fastapi-jwt) — we reuse token issuing at the end. A Google account for the credentials.

fastapi-oauth2-google/
ā”œā”€ā”€ requirements.txt
ā”œā”€ā”€ .env                    # secrets — gitignored
└── app/
    ā”œā”€ā”€ __init__.py
    ā”œā”€ā”€ settings.py         # google creds + secrets (pydantic-settings)
    ā”œā”€ā”€ oauth.py            # Authlib OAuth client registry
    ā”œā”€ā”€ security.py         # issue/verify YOUR app JWT
    ā”œā”€ā”€ models.py           # User (email, name, picture)
    ā”œā”€ā”€ database.py         # get_session
    └── main.py             # login + callback + protected route

How Social Login Works (and Why to Use It)

Rolling your own password auth means hashing, reset emails, 2FA, breach monitoring — a lot of security surface. OAuth2 lets Google vouch for the user instead. Here's the authorization code flow:

  Browser            Your FastAPI              Google
     │                    │                      │
     │  GET /login        │                      │
     │───────────────────►│                      │
     │   302 redirect to Google (with state)     │
     │◄───────────────────┤                      │
     │   user logs in & consents ──────────────► │
     │                    │   redirect back with ?code=...
     │◄──────────────────────────────────────────┤
     │  GET /callback?code=...                    │
     │───────────────────►│  exchange code +     │
     │                    │  state ─────────────►│
     │                    │◄──── id_token + userinfo
     │   your app sets its OWN JWT session        │
     │◄───────────────────┤                      │
TermWhat it is
client_id / client_secretYour app's credentials from Google
redirect_uriWhere Google sends the user back (your callback)
codeOne-time code your server swaps for tokens
id_tokenA signed JWT from Google with the user's identity
stateAnti-CSRF value tying the redirect to this browser
šŸ’”
Ang importante dito: the client_secret and the code-for-token exchange happen server-side only. The browser never sees your secret. That's what makes the authorization code flow the right choice for a backend like FastAPI.

Create Google OAuth Credentials

In the Google Cloud Console:

StepWhere
Create/select a projectconsole.cloud.google.com
Configure the OAuth consent screenAPIs & Services → OAuth consent screen
Create an OAuth client IDAPIs & Services → Credentials → Create Credentials
Application typeWeb application
Authorized redirect URIhttp://localhost:8000/auth/google/callback

Copy the client ID and secret into a .env file, loaded via pydantic-settings:

app/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    google_client_id: str
    google_client_secret: str
    # secret for the temporary OAuth session (state/nonce) storage
    session_secret: str = "dev-only-change-me"
    # secret for signing YOUR app's JWTs
    jwt_secret: str = "dev-only-change-me"
    base_url: str = "http://localhost:8000"

    model_config = SettingsConfigDict(env_file=".env")


settings = Settings()
.env (never commit this)
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here
SESSION_SECRET=a-long-random-string
JWT_SECRET=another-long-random-string
āš ļø
Secrets never touch git. Add .env to .gitignore and generate real random secrets (python -c "import secrets; print(secrets.token_urlsafe(32))"). The redirect URI in your code must match what you registered in Google exactly — protocol, host, port, path.

Install & Configure Authlib

Authlib handles the OAuth mechanics. Registering the google provider with its OpenID discovery URL means Authlib auto-configures every endpoint (authorize, token, JWKS) for you.

app/oauth.py
from authlib.integrations.starlette_client import OAuth

from app.settings import settings

oauth = OAuth()
oauth.register(
    name="google",
    client_id=settings.google_client_id,
    client_secret=settings.google_client_secret,
    # discovery doc: Authlib reads all Google endpoints + keys from here
    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid email profile"},
)
app/main.py (app + session middleware)
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware

from app.settings import settings

app = FastAPI(title="FastAPI Google OAuth", version="0.1.0")

# REQUIRED: Authlib stores the OAuth state/nonce in the session
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret)
āš ļø
The #1 Authlib gotcha: without SessionMiddleware, the callback fails with mismatching_state or CSRF Warning! State not equal. Authlib needs a session to remember the state it generated for the redirect. Add it before you touch the routes.

The Login Redirect

The login route just hands off to Google. authorize_redirect builds the Google URL (with your scopes and a fresh state) and returns a 302 — the user is bounced to Google to log in and consent.

app/main.py
from fastapi import Request

from app.oauth import oauth


@app.get("/auth/google/login")
async def google_login(request: Request):
    # must match the URI registered in Google; name= links to the callback route
    redirect_uri = request.url_for("google_callback")
    return await oauth.google.authorize_redirect(request, redirect_uri)
šŸ’”
request.url_for("google_callback") builds the callback URL from the route's name (set in the next step). Behind a proxy/HTTPS this should resolve to your public https:// URL — see Production Notes so it doesn't generate an http:// URI Google rejects.

The Callback: Exchange Code for Identity

Google redirects back to your callback with a ?code=.... One Authlib call — authorize_access_token — verifies the state, exchanges the code for tokens, validates Google's signed id_token, and hands you the parsed user info.

app/main.py
from fastapi import HTTPException, status
from authlib.integrations.starlette_client import OAuthError


@app.get("/auth/google/callback", name="google_callback")
async def google_callback(request: Request, session: SessionDep):
    try:
        token = await oauth.google.authorize_access_token(request)
    except OAuthError:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OAuth failed")

    # with the openid scope, Authlib parses the id_token into userinfo
    userinfo = token["userinfo"]

    # trust the email ONLY if Google says it's verified
    if not userinfo.get("email_verified"):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email not verified")

    user = upsert_user(session, userinfo)          # step 6
    app_jwt = create_access_token(str(user.id))     # step 7
    return {"access_token": app_jwt, "token_type": "bearer"}
āš ļø
Always check email_verified. A raw email claim can belong to an unverified address. Treating an unverified email as identity is an account-takeover vector — gate on email_verified before you trust it.

Upsert the User (Find or Create)

First login? Create the user. Returning user? Update their profile and move on. Match on the verified email — that's the stable identity across logins.

app/models.py
from sqlmodel import Field, SQLModel


class User(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    email: str = Field(index=True, unique=True)
    full_name: str = ""
    picture: str = ""
    # note: no password column — Google handles authentication
app/main.py
from sqlmodel import Session, select

from app.models import User


def upsert_user(session: Session, userinfo: dict) -> User:
    email = userinfo["email"]
    user = session.exec(select(User).where(User.email == email)).first()

    if user is None:
        user = User(email=email)     # first login -> create

    # keep profile fresh on every login
    user.full_name = userinfo.get("name", "")
    user.picture = userinfo.get("picture", "")

    session.add(user)
    session.commit()
    session.refresh(user)
    return user
šŸ’”
Notice the User has no password column. With social login your DB stores identity, not credentials — one less secret to protect. If you later add password login too, key both to the same email.

Issue Your Own JWT (Don't Reuse Google's)

Critical design point: Google's token is proof of login, not your session. After verifying identity, mint your own JWT. Your app controls its expiry, claims, and revocation — and your protected routes only ever check your token.

app/security.py
from datetime import datetime, timedelta, timezone

import jwt   # PyJWT

from app.settings import settings

ALGORITHM = "HS256"


def create_access_token(subject: str, expires_minutes: int = 60) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": subject,                                   # your user id
        "iat": now,
        "exp": now + timedelta(minutes=expires_minutes),
    }
    return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)


def decode_access_token(token: str) -> dict:
    return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
app/main.py (a protected route uses YOUR token)
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

bearer = HTTPBearer()


def current_user(
    creds: HTTPAuthorizationCredentials = Depends(bearer),
    session: SessionDep = None,
) -> User:
    try:
        payload = decode_access_token(creds.credentials)
    except jwt.PyJWTError:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
    user = session.get(User, int(payload["sub"]))
    if user is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
    return user


@app.get("/me")
def me(user: User = Depends(current_user)):
    return {"id": user.id, "email": user.email, "name": user.full_name}
āœ…
Clean separation: Google authenticates once; your JWT authorizes every request after. See fastapi-jwt for refresh tokens, and fastapi-cookies if you'd rather store the session token in a secure httpOnly cookie than return it in JSON.

Try It

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
authlib==1.3.2
httpx==0.27.2                # Authlib uses it for the token exchange
itsdangerous==2.2.0         # required by SessionMiddleware
pyjwt==2.9.0
sqlmodel==0.0.22
pydantic-settings==2.5.2
1Run the app
run.sh
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
2Do the login in a browser

Open http://localhost:8000/auth/google/login. You'll be sent to Google, consent, and land back on the callback, which returns your app JWT:

# after the browser round trip, the callback responds:
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer"
}
3Call a protected route with your token
$ curl -s http://127.0.0.1:8000/me \
    -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
{"id":1,"email":"[email protected]","name":"Your Name"}
āœ…
You did it: a full "Sign in with Google" flow — no passwords stored, identity verified by Google, and your own JWT guarding every route afterward.

Common Mistakes (and Fixes)

MistakeSymptomFix
No SessionMiddlewaremismatching_state / CSRF error at callbackAdd SessionMiddleware before the routes
redirect_uri mismatchGoogle: redirect_uri_mismatchMatch the URI in Google exactly (host, port, path)
Ignoring email_verifiedUnverified emails treated as identityReject when email_verified is false
Using Google's token as your sessionYou can't control expiry/revocationIssue and verify your own JWT
Committing .env / secretsLeaked client secretGitignore .env; rotate if leaked
HTTP redirect URI in prodGoogle rejects; insecure flowUse HTTPS + trust proxy headers (see below)

Production Notes

  • HTTPS + correct redirect URL. Behind a proxy, request.url_for can build an http:// URL. Add ProxyHeadersMiddleware (or trust X-Forwarded-Proto) so it generates the https:// URI Google expects, and register that exact URI.
  • Prefer an httpOnly cookie for browser apps. Returning the JWT in JSON is fine for APIs; for a website, set it in a secure, httpOnly, SameSite cookie (fastapi-cookies) so JS can't read it.
  • Rate-limit the auth routes. Login endpoints are abuse targets — pair this with fastapi-rate-limiting.
  • Plan for multiple providers. The same pattern (register provider → redirect → callback → upsert → your JWT) extends to GitHub, Microsoft, etc. Keep a provider + provider_id on the user if you support several.

What's Next

āœ…
šŸš€ Recommended next reads:
  • fastapi-jwt — refresh tokens and deeper JWT handling for the session you just issued
  • fastapi-cookies — store the session token in a secure httpOnly cookie
  • fastapi-rbac — add roles/permissions on top of the logged-in user
  • fastapi-rate-limiting — protect the auth routes from abuse

Recap: social login lets Google own authentication while your app owns the session. Register the provider with Authlib, add SessionMiddleware, redirect to Google, verify the callback (state + verified email), upsert the user, and issue your own JWT for every request afterward. No passwords, less risk, better UX.

fastapi-oauth2-google/              # āœ… finished
ā”œā”€ā”€ requirements.txt                # + authlib, itsdangerous, pyjwt
ā”œā”€ā”€ .env                            # gitignored secrets
└── app/
    ā”œā”€ā”€ settings.py         # google creds + secrets
    ā”œā”€ā”€ oauth.py            # Authlib google provider
    ā”œā”€ā”€ security.py         # your JWT create/decode
    ā”œā”€ā”€ models.py           # User (no password column)
    ā”œā”€ā”€ database.py         # get_session
    └── main.py             # login, callback, /me protected route

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.