All writing

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

  1. User hits /auth/login/google, we redirect to Google's consent screen.
  2. User login their google account.
  3. Google redirects back to our callback with a single-use code.
  4. We trade that code for the user's identity, server-to-server, with the client secret. The browser never sees it.
  5. 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

app/core/config.py
GOOGLE_CLIENT_ID: str
GOOGLE_CLIENT_SECRET: SecretStr
GOOGLE_REDIRECT_URI: str
GOOGLE_AUTHORIZE_URL: str
GOOGLE_TOKEN_URL: str
GOOGLE_SCOPES: str

GOOGLE_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

app/auth/routers/login_google.py
@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 redirect

The URL builder:

app/core/security.py
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

app/auth/routers/callback_google.py
@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

app/core/security.py
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, name

Two 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

app/auth/routers/callback_google.py
    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 redirect

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