Google Login with FastAPI and a React SPA

· min read

“Continue with Google” is one of the first things users expect on a sign-up page, and one of the easiest to get subtly wrong in a single-page app (SPA). This post adds it to a React SPA with a FastAPI backend, with no auth library beyond Google’s own token check.

1. How it works

The whole login happens on your backend. The SPA only provides the button.

  1. The user clicks “Continue with Google”. It’s a plain link to your backend.
  2. The backend redirects them to Google’s sign-in page.
  3. Google sends them back to your backend with a one-time code.
  4. The backend trades the code for the user’s email, sets a session cookie, and redirects to your app.

Sequence diagram of Google login: the React app links to FastAPI, FastAPI redirects to Google, Google redirects back to FastAPI with a code, FastAPI exchanges the code for an ID token, sets a session cookie, and the app calls /me

Doing it on the backend means your Google client secret never reaches the browser, and the session ends up in an HTTP-only cookie that JavaScript can’t read.

2. Get Google credentials

In the Google Cloud console, create an OAuth client ID of type “Web application”. Add this as an authorized redirect URI:

http://localhost:8000/auth/google/callback

Google gives you a client ID and a client secret. Put them in your backend’s environment as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, and install the two libraries the backend needs:

pip install httpx google-auth requests

3. The login route

The button links here. The route builds Google’s sign-in URL and redirects to it:

import os
import secrets
from urllib.parse import urlencode

from fastapi.responses import RedirectResponse

GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GOOGLE_CLIENT_SECRET = os.environ["GOOGLE_CLIENT_SECRET"]
API_URL = "http://localhost:8000"
APP_URL = "http://localhost:5173"
REDIRECT_URI = f"{API_URL}/auth/google/callback"


@app.get("/auth/google/login")
async def google_login():
    state = secrets.token_urlsafe(16)
    params = {
        "client_id": GOOGLE_CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "response_type": "code",
        "scope": "openid email profile",
        "state": state,
    }
    response = RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}")
    response.set_cookie("oauth_state", state, max_age=600, httponly=True, samesite="lax")
    return response

The state is a random value that goes to Google and comes back unchanged. Keeping a copy in a cookie lets the callback check that this browser really started the login. Without it, an attacker could send someone a callback link that silently logs them into the attacker’s account.

4. The callback route

Google redirects back here with a code and the state. Check the state, trade the code for an ID token, and verify it:

import httpx
from fastapi import HTTPException, Request
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token


@app.get("/auth/google/callback")
async def google_callback(request: Request, code: str | None = None, state: str | None = None):
    saved_state = request.cookies.get("oauth_state")
    if not code or not state or not saved_state or not secrets.compare_digest(state, saved_state):
        return RedirectResponse(f"{APP_URL}/login?error=google")

    async with httpx.AsyncClient() as client:
        res = await client.post(
            "https://oauth2.googleapis.com/token",
            data={
                "client_id": GOOGLE_CLIENT_ID,
                "client_secret": GOOGLE_CLIENT_SECRET,
                "code": code,
                "grant_type": "authorization_code",
                "redirect_uri": REDIRECT_URI,
            },
        )
        res.raise_for_status()

    info = id_token.verify_oauth2_token(
        res.json()["id_token"], google_requests.Request(), GOOGLE_CLIENT_ID
    )
    if info.get("email_verified") is not True:
        return RedirectResponse(f"{APP_URL}/login?error=unverified")

    ...  # next section

verify_oauth2_token checks that Google issued the token, that it was issued for your client ID, and that it hasn’t expired. The email_verified check matters too: only trust the email when Google says the person proved they own it.

5. Log them in

Now you know who the user is. Find or create their account, start a session, and send them back to the app:

    # ...continuing google_callback
    user_id = await find_or_create_user(db, info["email"], info.get("name", ""))
    token = await create_session(db, user_id)

    response = RedirectResponse(APP_URL)
    response.set_cookie(
        "session", token, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30
    )
    response.delete_cookie("oauth_state")
    return response

The helpers are small. Sessions are rows in Postgres, and the cookie holds a random token whose hash is stored in the row:

import hashlib


async def find_or_create_user(db, email: str, name: str) -> int:
    user_id = await db.fetchval("SELECT id FROM users WHERE email = $1", email)
    if user_id is None:
        user_id = await db.fetchval(
            "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id", email, name
        )
    return user_id


async def create_session(db, user_id: int) -> str:
    token = secrets.token_urlsafe(32)
    await db.execute(
        """
        INSERT INTO sessions (token_hash, user_id, expires_at)
        VALUES ($1, $2, now() + interval '30 days')
        """,
        hashlib.sha256(token.encode()).hexdigest(),
        user_id,
    )
    return token


@app.get("/me")
async def me(request: Request):
    token = request.cookies.get("session", "")
    user = await db.fetchrow(
        """
        SELECT u.email, u.name FROM sessions s JOIN users u ON u.id = s.user_id
        WHERE s.token_hash = $1 AND s.expires_at > now()
        """,
        hashlib.sha256(token.encode()).hexdigest(),
    )
    if user is None:
        raise HTTPException(status_code=401)
    return dict(user)

Add secure=True to both cookies once you’re on HTTPS. If your app only has Google login, this is complete. If it also has email/password sign-up, read the first point under “What this version leaves out”.

6. The React side

The button is a plain link, not a fetch call. The browser has to actually go to Google’s page, and a fetch can’t follow a redirect to another site’s sign-in screen.

After login, the SPA can’t read the HTTP-only cookie, so it asks the backend who is logged in:

import { useEffect, useState } from 'react';

const API = import.meta.env.VITE_API_BASE_URL;

type User = { email: string; name: string };

export function GoogleLogin() {
	const [user, setUser] = useState<User | null>(null);

	useEffect(() => {
		fetch(`${API}/me`, { credentials: 'include' })
			.then((res) => (res.ok ? res.json() : null))
			.then(setUser);
	}, []);

	if (user) return <p>Signed in as {user.email}</p>;
	return <a href={`${API}/auth/google/login`}>Continue with Google</a>;
}

credentials: 'include' is what makes the browser send the session cookie to the API. Your FastAPI CORS setup also needs allow_credentials=True and your app’s exact origin in allow_origins. We set both up in How to Connect a React SPA to a FastAPI Backend.

7. Errors you will hit

  • redirect_uri_mismatch on Google’s page. The redirect URI in the console must match REDIRECT_URI exactly: scheme, host, port, and path. 127.0.0.1 and localhost count as different hosts.
  • Login works, but /me returns 401. The browser isn’t sending the cookie. Check credentials: 'include' and the CORS settings above. In production, keep the app and the API on the same domain (app.example.com and api.example.com). A frontend on vercel.app and an API on fly.dev are different sites, and the browser won’t send the cookie between them.
  • The state check fails every time. The oauth_state cookie must be samesite="lax", not "strict". Google’s redirect back to you is a cross-site navigation, and the browser withholds strict cookies on exactly that request.

What this version leaves out

The version above works. Once real users depend on it, these are the gaps you’ll hit next:

  • Accounts that also have a password. If your app has email/password sign-up too, matching by email has a trap. Someone can register with another person’s email, never verify it, and wait. When the real owner later clicks “Continue with Google”, the code above logs them into that account, and the attacker still knows its password. A production version notices the account was never verified, removes the old password, and signs out its sessions first.
  • Friendly errors. When a user clicks “Cancel” on Google’s screen, Google sends them back with error=access_denied. The code above shows them a generic error, and a failed token exchange shows a raw 500 page from your API. Every failure should land on your login page with a message that says what happened.
  • Invite-only sign-up. Team apps usually don’t let any Google account in, only people invited to an organization. The code above creates an account for anyone.
  • More providers. GitHub and Microsoft follow the same steps with different URLs and their own quirks.

FastReact ships Google login already built, next to email/password sign-up: the account-linking rules above, a clear message for every failure, invite-only mode for team apps, and HTTP-only session cookies, all covered by tests and a security audit. The Google OAuth docs show the setup.

logo-light

Ship production-ready SaaS applications with FastAPI + React. Complete authentication, payments, multi-tenancy, and admin dashboards - deploy anywhere with zero vendor lock-in.

© 2026 FastReact. All rights reserved.

🌼 Made with daisyUI

FASTREACT