šÆ 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 routeHow 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 ā
āāāāāāāāāāāāāāāāāāāāā⤠ā| Term | What it is |
|---|---|
client_id / client_secret | Your app's credentials from Google |
redirect_uri | Where Google sends the user back (your callback) |
code | One-time code your server swaps for tokens |
id_token | A signed JWT from Google with the user's identity |
state | Anti-CSRF value tying the redirect to this browser |
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:
| Step | Where |
|---|---|
| Create/select a project | console.cloud.google.com |
| Configure the OAuth consent screen | APIs & Services ā OAuth consent screen |
| Create an OAuth client ID | APIs & Services ā Credentials ā Create Credentials |
| Application type | Web application |
| Authorized redirect URI | http://localhost:8000/auth/google/callback |
Copy the client ID and secret into a .env file, loaded via pydantic-settings:
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()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.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.
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"},
)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)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.
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.
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"}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.
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 authenticationfrom 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 userUser 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.
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])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}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
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.2python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reloadOpen 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"
}$ curl -s http://127.0.0.1:8000/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
{"id":1,"email":"[email protected]","name":"Your Name"}Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
No SessionMiddleware | mismatching_state / CSRF error at callback | Add SessionMiddleware before the routes |
| redirect_uri mismatch | Google: redirect_uri_mismatch | Match the URI in Google exactly (host, port, path) |
Ignoring email_verified | Unverified emails treated as identity | Reject when email_verified is false |
| Using Google's token as your session | You can't control expiry/revocation | Issue and verify your own JWT |
| Committing .env / secrets | Leaked client secret | Gitignore .env; rotate if leaked |
| HTTP redirect URI in prod | Google rejects; insecure flow | Use HTTPS + trust proxy headers (see below) |
Production Notes
- HTTPS + correct redirect URL. Behind a proxy,
request.url_forcan build anhttp://URL. AddProxyHeadersMiddleware(or trustX-Forwarded-Proto) so it generates thehttps://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,
SameSitecookie (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_idon the user if you support several.
What's Next
fastapi-jwtā refresh tokens and deeper JWT handling for the session you just issuedfastapi-cookiesā store the session token in a secure httpOnly cookiefastapi-rbacā add roles/permissions on top of the logged-in userfastapi-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