Google Login with FastAPI and a React SPA
“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.
- The user clicks “Continue with Google”. It’s a plain link to your backend.
- The backend redirects them to Google’s sign-in page.
- Google sends them back to your backend with a one-time code.
- The backend trades the code for the user’s email, sets a session cookie, and redirects to your app.
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_mismatchon Google’s page. The redirect URI in the console must matchREDIRECT_URIexactly: scheme, host, port, and path.127.0.0.1andlocalhostcount as different hosts.- Login works, but
/mereturns 401. The browser isn’t sending the cookie. Checkcredentials: 'include'and the CORS settings above. In production, keep the app and the API on the same domain (app.example.comandapi.example.com). A frontend onvercel.appand an API onfly.devare different sites, and the browser won’t send the cookie between them. - The state check fails every time. The
oauth_statecookie must besamesite="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.
