Google OAuth authentication for GlideComp using Better Auth, Hono, and Cloudflare D1.
Browser Cloudflare
┌─────────────┐ ┌──────────────────────────────────────────┐
│ /u/{user}/ │──────────▶│ Pages Function (functions/api/auth/) │
│ /onboarding │ /api/auth│ │ service binding │
│ /u/{user}/ │◀──────────│ ▼ │
│ /analysis │ │ auth-api Worker (Hono + Better Auth) │
└─────────────┘ │ ↕ │
│ D1 (taskscore-auth) │
└──────────────────────────────────────────┘
- Pages Function at
/api/auth/*proxies requests to the auth-api worker via a service binding (seefunctions/api/auth/[[path]].tsand rootwrangler.toml) - Auth worker handles all auth logic (Hono + Better Auth + D1)
- Frontend pages served by Cloudflare Pages (static)
/u/*rewritten todashboard.htmlvia_redirects(200 rewrite, URL preserved)
1. User clicks "Login with Google" on index or dashboard
2. Better Auth client calls signIn.social({ provider: "google" })
3. Browser redirects to Google consent screen
4. Google redirects back to /api/auth/callback/google
5. Better Auth creates/updates user + session in D1, sets session cookie
6. Browser redirects to /u/me/ (callbackURL) which loads dashboard.html
7. dashboard.ts detects session:
- Has username? → show dashboard
- No username? → redirect to /onboarding.html
8. User picks a username on onboarding page
9. POST /api/auth/set-username → redirect to /u/{username}/
| File | Purpose |
|---|---|
src/index.ts |
Hono app with CORS, /me, /set-username, and Better Auth catch-all |
src/auth.ts |
Better Auth config: Kysely D1 dialect, Google social provider, username field |
src/db/schema.sql |
D1 schema: user, session, account, verification tables |
wrangler.toml |
D1 binding, route config, env vars |
| File | Purpose |
|---|---|
auth/client.ts |
Better Auth client SDK + helper functions (signInWithGoogle, signOut, getCurrentUser, setUsername) |
| Page | File | Purpose |
|---|---|---|
| Onboarding | onboarding.html + onboarding.ts |
Username picker for new users |
| Dashboard | dashboard.html + dashboard.ts |
Welcome page at /u/{username}/, shows Google sign-in if not authenticated |
| Method | Path | Auth Required | Description |
|---|---|---|---|
| GET | /api/auth/me |
No | Returns { user } or { user: null } |
| POST | /api/auth/set-username |
Yes | Sets username (3-20 chars, [a-zA-Z0-9-]) |
| ALL | /api/auth/* |
— | Better Auth handles sign-in, callback, sign-out, session |
Secrets are scoped to the auth-api worker. The worker must be deployed first before secrets can be set.
# 1. Deploy the worker (creates it on Cloudflare)
cd web/workers/auth-api
bun run wrangler deploy
# 2. Set secrets (each prompts for the value interactively)
bun run wrangler secret put GOOGLE_CLIENT_ID
bun run wrangler secret put GOOGLE_CLIENT_SECRET
bun run wrangler secret put BETTER_AUTH_SECRET
# 3. Re-deploy to pick up the secrets
bun run wrangler deploy| Secret | Description |
|---|---|
GOOGLE_CLIENT_ID |
Google OAuth 2.0 client ID |
GOOGLE_CLIENT_SECRET |
Google OAuth 2.0 client secret |
BETTER_AUTH_SECRET |
Random secret for signing sessions/tokens (generate with openssl rand -base64 32) |
Set in wrangler.toml (production) or .dev.vars (local dev override):
| Variable | Production Value | Dev Value |
|---|---|---|
BETTER_AUTH_URL |
https://glidecomp.com |
http://localhost:3000 |
- Create OAuth 2.0 credentials in Google Cloud Console
- Set authorized redirect URIs:
- Production:
https://glidecomp.com/api/auth/callback/google - Development:
http://localhost:3000/api/auth/callback/google - No entry needed for preview deployments — handled by the oAuthProxy plugin (see below)
- Production:
cd web/workers/auth-api
# Create database (only needed once)
bun run wrangler d1 create taskscore-auth
# Copy database_id into wrangler.toml
# Apply schema to remote (production)
bun run wrangler d1 execute taskscore-auth --remote --file=src/db/schema.sql
# Apply schema to local (development)
bun run wrangler d1 execute taskscore-auth --local --file=src/db/schema.sqlThe auth worker requires nodejs_compat in wrangler.toml because Better Auth uses node:async_hooks. This is already configured:
compatibility_flags = ["nodejs_compat"]# Terminal 1: Auth worker on port 8788
bun run dev:auth
# Terminal 2: Frontend on port 3000 (proxies /api/auth → 8788)
bun run devThe Vite dev server proxies /api/auth requests to the auth worker, so cookies work on the same origin.
- Create
.dev.varsinweb/workers/auth-api/:
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
BETTER_AUTH_SECRET=your-random-secret
BETTER_AUTH_URL=http://localhost:3000
- Apply the D1 schema locally:
cd web/workers/auth-api
bun run wrangler d1 execute taskscore-auth --local --file=src/db/schema.sql| Component | Library | Why |
|---|---|---|
| Auth | Better Auth | TypeScript-first, supports social providers, runs on edge |
| Web framework | Hono | Lightweight, Cloudflare Workers native |
| Database | Cloudflare D1 | Serverless SQLite, no external DB needed |
| DB adapter | Kysely + kysely-d1 | Better Auth's built-in Kysely adapter with D1 dialect |
| Auth client | better-auth/client |
Tree-shakeable client SDK for browser |
Auth works on preview deployments (e.g. https://<hash>.glidecomp.pages.dev) via two mechanisms:
Preview deployments can't use the production worker route (glidecomp.com/api/auth/*). Instead, a Pages Function at functions/api/auth/[[path]].ts proxies all /api/auth/* requests to the auth-api worker via a Cloudflare service binding. This works on every deployment — production and preview — because service bindings are internal Cloudflare routing, not domain-based.
The binding is configured in the root wrangler.toml:
[[services]]
binding = "AUTH_API"
service = "auth-api"Google OAuth only has glidecomp.com registered as a redirect URI. When signing in from a preview deployment, the oAuthProxy plugin handles the flow:
- Preview server initiates OAuth, but the callback goes to production (
glidecomp.com) - Production exchanges the auth code for tokens and fetches user info
- Production encrypts the profile and redirects back to the preview origin
- Preview server decrypts, creates user/session locally, and sets the session cookie
This is configured in web/workers/auth-api/src/auth.ts:
plugins: [
oAuthProxy({
productionURL: "https://glidecomp.com",
}),
],
trustedOrigins: ["https://*.glidecomp.pages.dev"],Requirements:
- All environments must share the same
BETTER_AUTH_SECRET(the encryption key) - Preview origins must be in
trustedOrigins(wildcards supported) - On production (
baseURL === productionURL), the proxy is automatically disabled
The branch-deploy.yml workflow only deploys Cloudflare Pages — it does not deploy the auth-api or airscore-api workers. Workers are only deployed from master via deploy.yml. This prevents branches from overwriting production workers with untested code.
Other workers (e.g. competition-api) need to verify authentication status for incoming requests. There are three approaches, in order of complexity:
Add a service binding in the worker's wrangler.toml and forward the session cookie to /api/auth/me:
[[services]]
binding = "AUTH_API"
service = "auth-api"const res = await env.AUTH_API.fetch(new Request("https://auth/api/auth/me", {
headers: { cookie: request.headers.get("cookie") || "" }
}));
const { user } = await res.json();
if (!user) return new Response("Unauthorized", { status: 401 });- No shared secrets or new dependencies
- All auth logic stays centralised in auth-api
- ~5-10ms subrequest per authed call (hits D1 each time)
- Pages already uses this pattern (see
functions/api/auth/[[path]].ts)
Give the worker its own D1 binding to taskscore-auth plus BETTER_AUTH_SECRET. Parse the signed better-auth.session_token cookie, unsign it, and query the session table directly.
- No inter-worker subrequest
- Duplicates auth logic and must exactly match Better Auth's cookie signing (HMAC-SHA256 via
better-call) - Breaks if Better Auth changes its cookie format
Enable Better Auth's cookie caching with JWT strategy in auth-api, then verify the signed better-auth.session_data cookie in the calling worker without any DB or network call:
// In auth-api config:
session: { cookieCache: { enabled: true, maxAge: 5 * 60, strategy: "jwt" } }
// In competition-api:
import { getCookieCache } from "better-auth/cookies";
const session = await getCookieCache(request, { secret: env.BETTER_AUTH_SECRET, strategy: "jwt" });- Zero latency — truly stateless verification
- Requires
better-authas a dependency and sharingBETTER_AUTH_SECRET - Revoked sessions remain valid until
maxAgeexpires (e.g. 5 min window)
# Deploy auth worker
bun run deploy:auth
# Deploy frontend (includes auth pages)
bun run deploy