How to Implement Google OAuth in FastAPI
Most FastAPI OAuth tutorials hand you Authlib and move on. That works until something breaks and you have no idea which half of the flow failed.
I wrote the Google flow by hand for CodifyLive. It is about 100 lines, and Google is only used to prove who the user is. The session that follows is mine, my own access token and rotating refresh cookie, so there is nothing from Google worth storing long-term.
The flow
- User hits
/auth/login/google, we redirect to Google's consent screen. - User login their google account.
- Google redirects back to our callback with a single-use
code. - We trade that code for the user's identity, server-to-server, with the client secret. The browser never sees it.
- Find or create the user, issue our own session.
That is the authorization-code flow. Google never hands a credential to the browser, only a code that is worthless without the secret.
Config
GOOGLE_CLIENT_ID: str
GOOGLE_CLIENT_SECRET: SecretStr
GOOGLE_REDIRECT_URI: str
GOOGLE_AUTHORIZE_URL: str
GOOGLE_TOKEN_URL: str
GOOGLE_SCOPES: strGOOGLE_REDIRECT_URI has to match what is registered in the Google Cloud
console character for character. A trailing slash is enough to get
redirect_uri_mismatch before the user sees anything.
Step 1: redirect to Google
@router.get("/login/google")
async def login_google():
state = secrets.token_urlsafe(32)
redirect = RedirectResponse(
url=build_authorize_url(state=state),
status_code=status.HTTP_302_FOUND,
)
redirect.set_cookie(
key="state",
value=state,
max_age=600,
httponly=True,
secure=settings.IS_PRODUCTION,
samesite="Lax",
path="/auth",
)
return redirectThe URL builder:
def build_authorize_url(state: str) -> str:
params = {
"client_id": settings.GOOGLE_CLIENT_ID,
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": settings.GOOGLE_SCOPES,
"access_type": "online",
"prompt": "select_account",
"state": state,
}
return f"{settings.GOOGLE_AUTHORIZE_URL}?{urlencode(params)}"access_type=online means Google issues no refresh token of its own. I do not
want one. Google proves identity once, the session after that is mine.
The client navigates the browser here, not with fetch. The response is a
cross-origin redirect and a fetch would be blocked before it ever got there.
Step 2: the callback
@router.get("/google/callback")
async def google_callback(
db: Annotated[AsyncSession, Depends(get_db)],
state: str | None = Query(None),
state_cookie: str | None = Cookie(None, alias="state"),
code: str | None = Query(None),
error: str | None = Query(None),
):
if error or not code or not state or not state_cookie:
raise HTTPException(400, detail="Google login error.")
if not secrets.compare_digest(state, state_cookie):
raise HTTPException(400, detail="Google login error.")
token_data = {
"code": code,
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET.get_secret_value(),
"redirect_uri": settings.GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
}
email, name = await exchange_google_auth_for_token(token_data=token_data)
user = await get_or_create_google_user(email, name, db)Step 3: exchange the code
async def exchange_google_auth_for_token(token_data) -> tuple[str, str]:
async with httpx.AsyncClient() as client:
token_response = await client.post(settings.GOOGLE_TOKEN_URL, data=token_data)
if token_response.status_code != 200:
raise HTTPException(400, detail="Google login error.")
tokens = token_response.json()
try:
user_info = id_token.verify_oauth2_token(
tokens["id_token"], google_request.Request(), settings.GOOGLE_CLIENT_ID
)
email = user_info.get("email").lower().strip()
name = user_info.get("name").title().strip()
email_verified = user_info.get("email_verified")
except ValueError as e:
raise HTTPException(401, detail="Google login error.") from e
if not email_verified:
raise HTTPException(400, detail="Google login error.")
return email, nameTwo things here matter more than the rest.
verify_oauth2_token verifies, it does not just decode. It checks the
signature against Google's published keys, the issuer, the expiry, and that
aud is this application's client id. Decoding without verifying would let
anyone mint an id_token for any email address.
And email_verified is not optional. Accounts are matched by email, which is
only safe because the address is proven. An unverified one would let someone
claim an account that is not theirs.
Step 4: issue the session
refresh_token = create_refresh_token()
db.add(
RefreshTokenModel(
user_id=user.id,
token_hash=refresh_token["stored"],
expires_at=datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
)
await db.commit()
redirect = RedirectResponse(
url=f"{settings.FRONTEND_URL}/auth/callback",
status_code=status.HTTP_303_SEE_OTHER,
)
redirect.delete_cookie(key="state", path="/auth")
set_refresh_cookie(redirect, refresh_token["raw"])
return redirectThe refresh token is opaque, not a JWT. Only its SHA-256 hash is stored, so the database never holds anything usable, and it is revocable because its meaning comes entirely from its row.
No access token in the URL. Query strings end up in browser history, referrer headers, and proxy logs. The frontend trades the refresh cookie for an access token when it lands, which is why it hits a dedicated callback route instead of going straight to the dashboard.
That is the whole thing
Redirect with a state, verify the state came back, exchange the code
server-side, verify the id_token, issue your own session. No library, and
when it breaks you know exactly which step to look at.
Full code here: github.com/fulanii/blog/posts/fastapi-google-oauth.