Featured image of post Hybrid Image Search Dev Log — Implementing the Google OAuth Login Wall

Hybrid Image Search Dev Log — Implementing the Google OAuth Login Wall

Adding Google OAuth to FastAPI and React, protecting the full API with JWT cookie-based authentication

Overview

I added Google OAuth login to the hybrid image search demo app. The app previously had no authentication — every API endpoint was wide open. The image generation feature calls the Gemini API and incurs real costs, so leaving it unprotected wasn’t an option. For this task, I ran the full cycle through the Claude Code superpowers plugin workflow: writing the design spec, spec review, implementation planning, coding, and security review. The result: 17 commits, a complete login wall.

Authentication Architecture

I went with Lightweight Custom Auth instead of a library. FastAPI-Users brings 15+ features I don’t need (password reset, email verification, etc.), and Authlib + Session Middleware uses server-side redirects that don’t fit a SPA architecture. Building it myself means I understand and can debug every line.

Core stack:

  • Backend: google-auth (Google ID token verification) + python-jose (JWT creation/verification)
  • Frontend: @react-oauth/google (Google Sign-In popup button)
  • Session: JWT stored in HttpOnly cookie (more XSS-resistant than localStorage)

Auth Flow

Database Changes

Adding the User Model

The app previously had four tables — SearchLog, ImageSelection, GenerationLog, ManualUpload — all recording actions anonymously. I created a new User table and added a user_id FK column to all four.

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, autoincrement=True)
    google_id = Column(String, unique=True, nullable=False, index=True)
    email = Column(String, unique=True, nullable=False)
    name = Column(String, nullable=False)
    picture_url = Column(String, nullable=True)
    generation_count = Column(Integer, default=0, nullable=False)
    last_active_at = Column(DateTime, nullable=True)
    created_at = Column(DateTime, nullable=False, server_default=func.now())

I left existing data untouched. The FK columns are declared nullable=True so existing rows stay as user_id=NULL, and only new rows get a user_id filled by the auth middleware. One Alembic migration handled table creation and FK additions.

Backend Implementation

auth.py — Authentication Module

All auth logic lives in backend/src/auth.py. Three core functions:

1. Google token verificationverify_google_token()

async def verify_google_token(token: str) -> dict:
    try:
        # verify_oauth2_token is synchronous and may fetch Google's public keys over the network
        idinfo = await asyncio.to_thread(
            id_token.verify_oauth2_token,
            token, google_requests.Request(), GOOGLE_CLIENT_ID
        )
        if idinfo["iss"] not in ("accounts.google.com", "https://accounts.google.com"):
            raise ValueError("Invalid issuer")
        return idinfo
    except ValueError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Invalid Google token: {e}",
        )

The security review flagged this: verify_oauth2_token() is synchronous and may perform a network I/O to fetch Google’s public keys. Calling it without asyncio.to_thread() blocks the event loop.

2. JWT cookie managementcreate_jwt() / set_auth_cookie()

The JWT carries only user_id and exp. Key cookie settings:

  • HttpOnly — JavaScript can’t read the token, preventing XSS theft
  • SameSite=Lax — CSRF protection (no extra CSRF token needed)
  • Secure — Active in production (HTTPS) only, disabled for local development

3. FastAPI Dependencyget_current_user()

async def get_current_user(access_token: str = Cookie(None)):
    if not access_token:
        raise HTTPException(status_code=401, detail="Not authenticated")
    try:
        payload = jwt.decode(access_token, JWT_SECRET, algorithms=["HS256"])
        user_id = payload.get("user_id")
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

    user = await get_user_by_id(user_id)
    if not user:
        raise HTTPException(status_code=401, detail="User not found")

    # Update last_active_at with throttling (once per minute)
    now = datetime.now(timezone.utc)
    if not user.last_active_at or (now - user.last_active_at).seconds > 60:
        await update_last_active(user.id)

    return user

Updating last_active_at on every request would put write pressure on SQLite, so it’s throttled to once per minute. I also created a get_optional_user() variant that returns None instead of 401, for the /api/auth/me endpoint.

Protecting Endpoints

I added user = Depends(get_current_user) to all 10 data-access endpoints. The image generation endpoint additionally calls increment_generation_count(user.id). All logging functions (log_search, log_image_selection, etc.) received a user_id parameter and now store it in the DB.

# Protected (get_current_user required)
POST /search, /search/simple, /search/hybrid, GET /search
POST /api/generate-image, /api/log-selection, /api/upload-reference-image
GET /api/history/generations, /api/images, /api/images/{image_id}

# Unprotected (no auth required)
GET /, /health, /api/info, /images/{filename}
POST /api/auth/google, /api/auth/logout
GET /api/auth/me

Frontend Login Flow

LoginPage Component

I used @react-oauth/google’s <GoogleLogin> component for popup-based login. Rather than a redirect flow, a Google account selection in the popup returns an ID token directly via callback.

// LoginPage.tsx
import { GoogleLogin, GoogleOAuthProvider } from '@react-oauth/google';

function LoginPage({ onLogin }: { onLogin: (user: UserProfile) => void }) {
  const handleSuccess = async (credentialResponse) => {
    const response = await loginWithGoogle(credentialResponse.credential);
    onLogin(response.user);
  };

  return (
    <GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
      <div className="login-container">
        <h1>Hybrid Image Search</h1>
        <GoogleLogin onSuccess={handleSuccess} onError={() => setError('Login failed')} />
      </div>
    </GoogleOAuthProvider>
  );
}

App.tsx Changes

Auth state is managed at the app entry point:

  1. On mount — call GET /api/auth/me. Success restores the existing session; 401 shows the login page.
  2. Conditional renderingauthLoading → spinner, !user<LoginPage>, else → main UI
  3. Logout — top-right button, calls POST /api/auth/logout then clears state
  4. Data loading guardif (!user) return; in useEffect prevents API calls before login
// App.tsx (core logic)
useEffect(() => {
  if (!user) return;
  const loadHistory = async () => {
    const items = await fetchGenerationHistory(20, 0);
    setGeneratedImages(mapHistoryItems(items));
  };
  loadHistory();
}, [user]);

api.ts — Axios Configuration

// withCredentials: true — browser automatically attaches cookie to requests
const api = axios.create({
  baseURL: API_BASE,
  withCredentials: true,
});

// 401 interceptor — redirect to login on token expiry
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      window.dispatchEvent(new Event('auth:logout'));
    }
    return Promise.reject(error);
  }
);

Security Review

After implementation, I ran /ship for a security review. Key findings and fixes:

ItemProblemFix
Google token verificationSync function blocking event loopWrap with asyncio.to_thread()
JWT secret not setAll auth fails silently on startup without a secretLog logger.warning in configure_auth()
create_jwt() guardSigning attempted when JWT_SECRET=NoneAdd guard raising RuntimeError
Frontend stylesHardcoded inline stylesConvert to Tailwind CSS classes
History loadingAPI calls attempted before loginAdd user dependency guard

Secret management was also considered up front. GOOGLE_OAUTH_CLIENT_ID and JWT_SECRET are loaded via os.getenv(), not from YAML config files. YAML is version-controlled, so secrets don’t belong there. Only non-secret config like token expiry lives in config.py’s AuthConfig.

Dev Tools: /ship Command and PostToolUse Hooks

I also set up project-specific dev tooling during this work.

PostToolUse hook — automatic type checking on every file edit:

  • .ts/.tsx files modified → tsc --noEmit runs automatically
  • backend/*.py files modified → pyright runs automatically

/ship command — six-step verification pipeline before each commit:

  1. Identify changed files
  2. Type validation (tsc + pyright)
  3. API contract sync check (schemas.pyapi.ts)
  4. Code simplification review
  5. Security review
  6. Auto-commit

One interesting debugging detour: the PostToolUse hook used $CLAUDE_FILE_PATH as an environment variable, but it didn’t work. Turns out hooks receive input via stdin JSON:

INPUT=$(cat)
FILEPATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

Commit Log

MessageKey files
docs: Google login design spec2026-03-17-google-login-design.md
docs: incorporate spec review feedbacksame
docs: fix endpoint path and description consistencysame
docs: write implementation plan2026-03-17-google-login.md
feat: User model + user_id FKmodels.py, Alembic migration
feat: google-auth, python-jose dependenciesrequirements.txt
feat: @react-oauth/google dependencypackage.json
feat: auth Pydantic schemasschemas.py
feat: user CRUD and activity trackingservice.py
feat: auth module (token verification + JWT cookie)auth.py
feat: AuthConfigconfig.py, default.yaml
feat: LoginPage componentLoginPage.tsx
feat: auth API functions, 401 interceptorapi.ts
feat: auth state, login/logout flowApp.tsx
feat: auth endpoints + full route protectionmain.py, service.py
fix: security guards, async token verification, UIauth.py, App.tsx
feat: Google OAuth login wall completefinal merge

Insights

HttpOnly cookie vs. localStorage — Many tutorials store JWTs in localStorage, but one XSS hit and the token is gone. HttpOnly cookies are completely inaccessible to JavaScript. When protecting a paid service like the Gemini API, this is the right choice. The implementation overhead over localStorage is basically just adding allow_credentials=True to the CORS config.

Design first, code later — This session followed the sequence: design spec → review → implementation plan → review → coding. It seems slower, but the spec review caught missing get_optional_user() pattern, inadequate secret loading strategy, and endpoint list mismatches — all before a line of code was written. Much cheaper to fix at that stage.

asyncio.to_thread() pattern — A common trap when using synchronous libraries in FastAPI. google.oauth2.id_token.verify_oauth2_token() makes an HTTP request internally. Calling it with no await freezes the event loop. Wrap it in asyncio.to_thread() to delegate to the thread pool.

Claude Code /ship workflow — Running type check → API contract sync → code review → security review → auto-commit in one pass noticeably improves commit quality. Automatically verifying that schemas.py and api.ts changed together was especially useful. The ability to build custom hooks and commands per project is one of Claude Code’s real strengths.

Built with Hugo
Theme Stack designed by Jimmy