This starter kit ships with Better Auth on the auth-worker. Email/password always works; Google and GitHub are optional — set credentials in env and the Continue with Google / GitHub buttons appear on /login.
Related: Auth worker checklist · .env.example (auth section) · GitHub Environments sync · Multi-environment (local / staging / prod)
Default bun run dev uses Portless (https://starter-web.localhost). Google’s console rejects *.localhost redirect URIs — so you register loopback only:
http://127.0.0.1:5173/api/auth/callback/google
(Match PORT in .env.local if you change it.)
With both Portless (default) and GOOGLE_* set, the auth worker enables Better Auth’s oAuthProxy plugin for Google only (including /link-social on /account): you keep browsing on Portless, but the OAuth redirect URI sent to Google uses 127.0.0.1. GitHub is unchanged — register https://<prefix>-web.localhost/api/auth/callback/github on your GitHub OAuth app (GitHub allows *.localhost; do not add the loopback GitHub URL unless you use LOCAL_PORTLESS=off). Sign-in with Google finishes via /oauth-proxy-callback; Connect Google on /account links to your existing session (no new user). After Google returns to loopback, sign-in redirects to https://<prefix>-web.localhost/api/auth/oauth-proxy-callback (not https://127.0.0.1 — HTTP-only, causes ERR_SSL_PROTOCOL_ERROR).
| Mode | Browse at | Google redirect URI in console | GitHub callback URL in OAuth app |
|---|---|---|---|
Portless + GOOGLE_* (default trick) |
https://starter-web.localhost |
http://127.0.0.1:5173/api/auth/callback/google |
https://starter-web.localhost/api/auth/callback/github |
LOCAL_PORTLESS=off |
http://127.0.0.1:5173 |
Same loopback URL | http://127.0.0.1:5173/api/auth/callback/github |
LOCAL_PORTLESS=off is still valid if you prefer one origin everywhere (no proxy). See §2.
| Env key | Where it comes from |
|---|---|
GOOGLE_CLIENT_ID |
Google Cloud Console → OAuth client |
GOOGLE_CLIENT_SECRET |
Same OAuth client |
GH_CLIENT_ID |
GitHub → OAuth App |
GH_CLIENT_SECRET |
Same OAuth App |
Use the GH_ prefix (not GITHUB_) so bun run github:sync:* can push these to GitHub Environment secrets — Actions rejects secret names starting with GITHUB_.
Both ID and secret must be non-empty for a provider to enable. The auth worker exposes availability at GET /api/auth/providers; the login page reads that via @internal/auth-client.
No app code changes are required — only env + provider console setup.
In repo-root .env.local (and staging/production dotfiles when you deploy):
| Key | Purpose |
|---|---|
BETTER_AUTH_SECRET |
Session signing — openssl rand -base64 32 |
AUTH_ADMIN_SECRET |
Machine admin API (trusted origins automation) |
AUTH_BOOTSTRAP_ADMIN_EMAILS |
Comma-separated emails granted admin on first login |
Run:
bun run setup:localThen restart dev after adding OAuth keys: bun run dev.
OAuth redirect / callback URLs must match your web app origin exactly — the same host users open in the browser. Auth is proxied at /api/auth/* on the web worker (email, OAuth, and anonymous chat all use these same-origin routes).
Alchemy sets the auth worker AUTH_BASE_URL binding to that public web URL at dev/deploy time (there is no dotfile key for the URL).
Resolution order (first match wins):
- Local dev + Portless (default) —
https://<PRODUCT_PREFIX>-web.localhost(GitHub ✅ · Google ✅ with loopback callback in Google — see Local dev: Portless + Google) - Local dev +
LOCAL_PORTLESS=off—http://127.0.0.1:<port>(Google ✅) WEB_DOMAINS— first hostname (typical custom-domain setup; auth is proxied on the web worker at/api/auth/*)- workers.dev — inferred web worker URL when no custom domains are set (needs Cloudflare API creds at deploy)
Default recommendation: use the web-proxy pattern — set WEB_DOMAINS for production, or rely on workers.dev for staging-only stacks. AUTH_DOMAINS is ignored because auth-worker is service-binding only; register OAuth callbacks on the web origin.
For each environment, register:
https://<auth-base-host>/api/auth/callback/google
https://<auth-base-host>/api/auth/callback/github
Examples:
| Environment | Auth base URL | Callback notes |
|---|---|---|
| Local + Portless (default) | https://starter-web.localhost |
GitHub ✅ · Google ✅ (proxy; register loopback callback in Google) |
Local + LOCAL_PORTLESS=off |
http://127.0.0.1:5173 |
Google ✅ · GitHub ✅ (single origin) |
| Staging / production | https://app.example.com or workers.dev |
Google ✅ and GitHub ✅ |
| Custom domain | https://app.example.com |
https://app.example.com/api/auth/callback/google |
Replace google with github for GitHub. |
Important: Browsers and OAuth consoles must use the web origin in local dev — not the auth worker’s direct bind address (e.g. http://127.0.0.1:8784). Auth is forwarded from the web worker at /api/auth/*.
If you renamed the product, update PRODUCT_PREFIX in packages/alchemy-utils/src/worker-peer-scripts.ts first — the local Portless hostname follows it.
Use a GitHub OAuth App (Developer settings → OAuth Apps). Better Auth expects GH_CLIENT_ID and GH_CLIENT_SECRET (Better Auth GitHub).
-
Register a new OAuth application (or edit an existing one).
-
Fill in:
- Application name — e.g.
My App (local)/My App (staging)/My App (production) - Homepage URL — that stage’s web origin, e.g.
https://starter-web.localhostorhttps://starter-frontend-web-prod.lunix-ai.workers.dev - Authorization callback URL —
https://<web-origin>/api/auth/callback/github(see Step 0)
- Application name — e.g.
-
Click Register application (or Update application).
One callback URL per OAuth App — for local + staging + production, create three OAuth Apps (see §4 Multiple environments).
On the OAuth App page:
- Client ID →
GH_CLIENT_ID - Generate a new client secret →
GH_CLIENT_SECRET(copy once; GitHub shows it only briefly)
In .env.local:
GH_CLIENT_ID=Ov23li...
GH_CLIENT_SECRET=...Re-run bun run setup:local if you use the setup browser, then bun run dev.
Unlike Google, a classic GitHub OAuth App has only one Authorization callback URL in the form. You cannot list local, staging, and production hosts on the same app.
Recommended: create one OAuth App per environment (e.g. My App (local), My App (staging), My App (production)), each with that stage’s callback URL, and put the matching GH_CLIENT_ID / GH_CLIENT_SECRET in the right dotfile:
| Stage | Callback URL (example) | Env file |
|---|---|---|
| Local + Portless | https://<prefix>-web.localhost/api/auth/callback/github |
.env.local |
Local + LOCAL_PORTLESS=off |
http://127.0.0.1:5173/api/auth/callback/github |
.env.local |
| Staging | https://<staging-web-host>/api/auth/callback/github |
.env.staging |
| Production | https://<prod-web-host>/api/auth/callback/github |
.env.production |
GitHub’s redirect URL rules allow extra paths under the registered callback on the same host and port — that does not help across *.localhost, workers.dev, and your prod domain. You need separate OAuth Apps (or test GitHub on one host only).
For staging/production, sync secrets after editing dotfiles:
bun run setup:staging && bun run github:sync:staging
# or
bun run setup:prod && bun run github:sync:prod- Open
/login— Continue with GitHub should appear. - Complete sign-in → land on
/(or yourredirectTo). - Visit
/account— profile should show your GitHub identity. - If your GitHub primary email is listed in
AUTH_BOOTSTRAP_ADMIN_EMAILS,/adminshould work after first login.
Google’s console has two separate pieces. Finishing Getting started (consent / branding) is not enough — this app needs an OAuth client (Client ID + secret). Until you create one, OAuth overview shows “You haven’t configured any OAuth clients for this project yet.” and /login will not show Google.
Where to work in the console: pick your project (top bar) → left nav Google Auth Platform (or search “OAuth” in the top search box). You’ll see Overview, Branding, Audience, Clients, etc.
If you already ran Getting started, you can skip most of this and only confirm the items below.
- Open Google Cloud Console and select your project (e.g. “CF Multiworker Starter Kit”).
- Go to Google Auth Platform (hamburger menu → APIs & Services may also show OAuth consent screen on older layouts — same project, same data).
- Branding (replaces much of the old “OAuth consent screen” wizard):
- App name, support email, developer contact — required for sign-in.
- User type External (public) or Internal (Google Workspace only).
- Audience (Google Auth Platform → Audience):
- Testing — only Test users you list can sign in. Use this while building locally and on staging before you are ready for the public.
- In production (published) — any Google user can sign in (subject to Google verification if you request sensitive scopes). Move here when staging smoke tests pass and you are ready for real users.
- Scopes: basic sign-in uses
openid,email,profile(Better Auth requests these; defaults are usually fine). - Local + staging tip: keep Testing until you have verified deploys; add your own Google account (and teammates’) under Test users.
- You do not need extra APIs enabled for “Sign in with Google” on a web app — skip API Library unless Google prompts you for something specific.
Google’s console only accepts redirect URIs that are either:
- Loopback —
http://localhost:<port>orhttp://127.0.0.1:<port>(what Google documents for local testing), or - A real public hostname —
https://app.example.com, a workers.dev deploy URL, a tunnel URL, etc.
It rejects special/dev TLDs such as .localhost, .test, .local, and bare hostnames like myapp.local — you’ll see:
Invalid origin: Must end with a public top-level domain (such as .com or .org).
GitHub is more permissive (https://starter-web.localhost works). Google is not.
Authorized JavaScript origins can stay empty for this app (server redirect flow). You only need a valid Authorized redirect URI.
Keep Portless on. Register http://127.0.0.1:5173/api/auth/callback/google in Google Cloud (not https://starter-web.localhost). Click Continue with Google on https://starter-web.localhost — the auth worker proxies OAuth through loopback automatically.
Best when you want a single origin (no proxy) on your laptop.
- In
.env.local:LOCAL_PORTLESS=off
- Restart
bun run dev. Open the URL Vite prints — usuallyhttp://127.0.0.1:5173(orhttp://localhost:5173). - In the Google client, Authorized redirect URIs (match your port exactly):
If your dev server uses another port, copy it from the terminal / browser bar. You can add both
http://127.0.0.1:5173/api/auth/callback/googlelocalhostand127.0.0.1entries if you switch between them. - On your GitHub OAuth app, add the same loopback callback (keep
starter-web.localhosttoo if you still use Portless sometimes):http://127.0.0.1:5173/api/auth/callback/github
Trade-off: no Portless HTTPS locally while this is set; cookies and auth use plain HTTP on loopback (fine for dev).
- Develop day-to-day with default Portless →
https://starter-web.localhost+ GitHub + email. - Configure Google only on a deployed URL (PR preview or staging):
- Deploy once, note the web URL (e.g.
https://<worker>.workers.devorWEB_DOMAINS). - Register
https://<that-host>/api/auth/callback/googlein Google Cloud. - Put
GOOGLE_*in.env.stagingand test sign-in on the live preview.
- Deploy once, note the web URL (e.g.
No local Google button until you add loopback (Option A) or use staging (Option C).
Use when you want HTTPS and a real TLD locally (e.g. shareable link, closer to prod).
- Run the app (Portless or
LOCAL_PORTLESS=off— tunnel forwards to whatever port Vite uses, often5173). - Start a tunnel to that port, for example:
- Cloudflare Tunnel (
cloudflared tunnel --url http://127.0.0.1:5173) - ngrok (
ngrok http 5173)
- Cloudflare Tunnel (
- Copy the https public URL (e.g.
https://abc123.ngrok-free.app). - Google client redirect URI:
https://abc123.ngrok-free.app/api/auth/callback/google - Open the app through the tunnel URL when testing Google (not
starter-web.localhost). - If sign-in returns 403 / untrusted origin, add the tunnel origin in
/admin/originsorAUTH_SEED_ORIGINSin.env.local(comma-separatedhttps://...origins).
Tunnel URLs change when you restart the tunnel unless you use a reserved ngrok domain / named Cloudflare tunnel.
- Pick a hostname you control, e.g.
dev.yourdomain.com. - Point it to loopback in
/etc/hosts:127.0.0.1 dev.yourdomain.com - Register in Google:
https://dev.yourdomain.com/api/auth/callback/google - You need HTTPS on that host (reverse proxy, tunnel, or local TLS) and trusted origins configured — more setup than Option A; use if you already standardize on a dev subdomain.
| Approach | Why |
|---|---|
https://starter-web.localhost in Google console |
Google rejects *.localhost — register http://127.0.0.1:5173/... instead (proxy handles the rest) |
PORTLESS_TLD=test → https://starter-web.test |
.test is reserved; Google rejects it like .localhost |
myapp.local, .dev on loopback |
.dev has HSTS on the real internet; avoid for local fake domains |
nip.io / sslip.io |
Unreliable in Google’s validator; loopback is simpler |
Practical recommendation: use Option A (Portless + loopback callback in Google) for day-to-day local Google, Option B if you want one origin, or Option C for Google on staging only.
This is the step that actually produces GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.
From OAuth overview:
- Overview → Create OAuth client or Clients → Create client.
- Application type: Web application.
- Name — e.g.
starter local web. - Authorized JavaScript origins — leave empty, or add your origin from the table above (no path) if Google accepts it for your chosen option.
- Authorized redirect URIs — must match your chosen option in §2, e.g.
http://127.0.0.1:5173/api/auth/callback/googlefor loopback dev. - Create → copy Client ID and Client secret (secret shown once).
Older console path (same result): APIs & Services → Credentials → Create credentials → OAuth client ID → Web application → same origins and redirect URI as above.
After this, Overview → Metrics should stop saying “no OAuth clients,” and Clients should list your new Web client.
In .env.local:
GOOGLE_CLIENT_ID=123456789-....apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-...Re-run bun run setup:local if needed, then bun run dev.
See Multi-environment setup for a full playbook (one Google client vs several, dotfiles, GitHub sync, and callback URLs per stage).
While Audience is Testing, only Test users can sign in — even if redirect URIs and env are correct on staging.
Before opening to everyone:
- Complete Google’s verification if you use sensitive scopes beyond basic profile/email.
- Audience → set publishing status to In production (or Publish app on older layouts).
- Confirm Authorized redirect URIs on your Web client include every live host (staging + production), not only loopback.
/loginshows Continue with Google.- Sign in →
/accountshows your Google profile. - Bootstrap admin email (if configured) grants
/adminon first login with that email.
Use this when you want OAuth on laptop + staging + production (or PR previews). No app code changes — only env, provider consoles, and deploy URLs.
| Stage | STAGE / command |
Env dotfile | GitHub Environment secrets |
|---|---|---|---|
| Local | bun run dev (local) |
.env.local |
(optional; usually not synced) |
| Staging | bun run deploy:staging |
.env.staging |
staging via bun run github:sync:staging |
| PR preview | CI deploy (pr-<n>) |
.env.staging (same file) |
staging |
| Production | bun run deploy:prod |
.env.production |
production via bun run github:sync:prod |
Auth public URL for each deploy is computed at deploy time (not a dotfile key). See Step 0.
Google Web application clients allow multiple Authorized redirect URIs. Register every stage on one Google client, then use the same GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET in .env.local, .env.staging, and .env.production (sync with github:sync:*).
| Environment | Browse / auth base URL (typical) | Google redirect URI |
|---|---|---|
| Local + Portless | https://<prefix>-web.localhost |
http://127.0.0.1:5173/api/auth/callback/google |
| Staging (workers.dev) | https://<web-worker>-staging.<account>.workers.dev |
https://<that-host>/api/auth/callback/google |
| Production | https://app.example.com or workers.dev |
https://<that-host>/api/auth/callback/google |
Example (this repo’s naming): after bun run deploy:staging, the web URL might look like https://starter-frontend-web-staging.lunix-ai.workers.dev. Add to Google:
http://127.0.0.1:5173/api/auth/callback/google
https://starter-frontend-web-staging.lunix-ai.workers.dev/api/auth/callback/google
https://<prod-host>/api/auth/callback/google
Authorized JavaScript origins (optional): origins only, no path — e.g. https://starter-frontend-web-staging.lunix-ai.workers.dev.
Each OAuth App allows one Authorization callback URL. Use a separate app per stage:
| Environment | OAuth App name (example) | Authorization callback URL |
|---|---|---|
| Local + Portless | My App (local) |
https://<prefix>-web.localhost/api/auth/callback/github |
| Staging | My App (staging) |
https://<staging-web-host>/api/auth/callback/github |
| Production | My App (production) |
https://<prod-web-host>/api/auth/callback/github |
Put that app’s GH_CLIENT_ID / GH_CLIENT_SECRET in the matching dotfile (.env.local, .env.staging, .env.production). Replace <prefix> with PRODUCT_PREFIX from packages/alchemy-utils/src/worker-peer-scripts.ts.
Template repo examples: https://starter-web.localhost/..., https://starter-frontend-web-staging.lunix-ai.workers.dev/..., https://starter-frontend-web-prod.lunix-ai.workers.dev/....
Optional: separate Google clients per stage (isolated secrets or test Audience apps). Same dotfile / github:sync pattern as GitHub — different GOOGLE_* per .env.* file.
Every stage dotfile that should show OAuth buttons needs both ID and secret for each provider:
BETTER_AUTH_SECRET=...
AUTH_ADMIN_SECRET=...
AUTH_BOOTSTRAP_ADMIN_EMAILS=you@example.com
GOOGLE_CLIENT_ID=....apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-...
GH_CLIENT_ID=...
GH_CLIENT_SECRET=...Create or edit dotfiles:
bun run setup:local # → .env.local
bun run setup:staging # → .env.staging
bun run setup:prod # → .env.productionSync to GitHub (so deploy workflows receive secrets):
bun run github:sync:staging
bun run github:sync:prodSee GitHub Environments for DEPLOY_ENABLED and protection rules.
Alchemy does not write the auth URL into GitHub. After the first successful deploy:
- Read the deploy log / Cloudflare dashboard for the web worker URL (
apps/web), or open the workers.dev link Alchemy prints. - Register OAuth callbacks using that exact origin (see tables above).
- Smoke-test
https://<that-host>/login— Google/GitHub buttons appear only if secrets are in that stage’s dotfile and synced to the matching GitHub Environment.
Optional: set WEB_DOMAINS=app.example.com in .env.production (and staging if needed) before deploy so the URL is stable and known up front. See workers-env-local.
| Console area | What it controls |
|---|---|
| Audience → Testing / In production | Who may sign in (test users only vs everyone) |
| Clients → Authorized redirect URIs | Which hosts may complete OAuth (localhost, staging workers.dev, prod domain) |
You can mark Audience as In production while still adding staging + loopback URIs on one Web client — publishing affects user access, not which redirect URLs are valid.
Recommended path:
- Testing + test users — local dev and staging deploys.
- Register all redirect URIs (loopback + staging + prod) on one Web client.
- Smoke-test staging on the live workers.dev URL.
- Switch Audience to In production when ready for public Google users.
Same-repo PR deploys use STAGE=pr-<n> and the same GitHub Environment secrets as staging (.env.staging). Each preview gets its own workers.dev URL (e.g. https://<prefix>-frontend-web-pr-22.<account>.workers.dev).
GitHub OAuth Apps allow only one Authorization callback URL — there is no API to create or update OAuth Apps per PR. Register staging only:
https://<staging-web-host>/api/auth/callback/github
This starter kit proxies PR preview OAuth through staging (Better Auth oAuthProxy):
- User clicks Continue with GitHub on the PR preview URL (login or guest upgrade / Quick connect).
- GitHub redirects to the staging callback (registered above).
- Staging auth exchanges the code and redirects back to the PR preview with an encrypted profile.
- The PR preview auth worker creates the session in its own D1 database (sign-in) or links the provider on the existing guest account (
/link-social).
Stock Better Auth oAuthProxy only hooks /sign-in/social; this starter kit extends it with a composed plugin for /link-social on PR passthrough (see oauth-proxy-passthrough-patch.ts — createPassthroughOAuthProxyPlugin). Redeploy staging after changing that plugin — staging receives the OAuth callback before the preview finishes linking.
Requirements:
- Staging must stay deployed while testing OAuth on PR previews (the callback always hits staging first).
BETTER_AUTH_SECRETmust match between staging and PR previews (same GitHub Environment secret — already the case for preview deploys).GH_*credentials are the staging OAuth App (already synced to thestagingEnvironment).
Google on PR previews uses the same proxy — you only need the staging redirect URI in Google Cloud, not each pr-<n> URL.
The login and guest-upgrade pages show a short notice when passthrough OAuth is active. You can still test OAuth directly on the fixed staging URL without the proxy hop.
Not supported: automatically registering a new GitHub OAuth App per PR (GitHub does not expose that API).
- Core auth secrets in the stage dotfile (
BETTER_AUTH_SECRET,AUTH_ADMIN_SECRET,AUTH_BOOTSTRAP_ADMIN_EMAILS). GOOGLE_*andGH_*in that stage’s dotfile (both ID and secret for each provider you want).- Provider console: redirect/callback URLs for that stage’s live web host (not
127.0.0.1on staging/prod — loopback is local-only). - Google Audience: Test users while validating staging; In production when opening to all Google users.
bun run github:sync:stagingorgithub:sync:prodso CI deploy jobs receive secrets.bun run deploy:stagingorbun run deploy:prod, then smoke-test/loginand/accounton the live URL.- Connect provider on
/account— link errors should return to/account?error=...with a readable message (not/api/auth/error).
No auth URL is synced to GitHub — Alchemy derives it from the deployed web origin (WEB_DOMAINS or workers.dev) at deploy time. See Multi-environment setup if the URL was unknown before the first deploy.
| Symptom | Likely cause | Fix |
|---|---|---|
No Google/GitHub button on /login |
Missing or empty *_CLIENT_ID / *_CLIENT_SECRET |
Set both keys; restart bun run dev |
| Provider error: redirect URI mismatch | Callback URL in console ≠ resolved auth URL | Fix console URL; check WEB_DOMAINS, workers.dev, or the Portless hostname |
| 403 on sign-in (especially anonymous → OAuth) | Untrusted Origin |
Add origin in /admin/origins or AUTH_SEED_ORIGINS; see auth-setup |
| Google “Access blocked” / app not verified | Consent screen still in Testing | Audience → Test users — add your Google account, or publish the app |
| OAuth overview: “no OAuth clients yet” | Only finished Getting started / branding | Overview → Create OAuth client or Clients → Create client (Web application) — see §3 |
Google: “Invalid origin” for *.localhost / *.test |
Google only allows loopback or real public domains | LOCAL_PORTLESS=off + http://127.0.0.1:5173/..., tunnel, or staging — see §2 |
Created client but no button on /login |
Env keys missing or dev not restarted | Set both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env.local, then bun run dev |
| Signed in but not admin | Email not in bootstrap list | Add email to AUTH_BOOTSTRAP_ADMIN_EMAILS, redeploy, then bun run auth:sync-bootstrap-admins (CI runs this after deploy) — or promote via admin UI / DB |
- Browser hits
/api/auth/*on the web worker → forwarded toenv.AUTH(auth-workerservice binding). - Better Auth
socialProvidersinworkers/auth-worker/src/auth.tsenable Google/GitHub when env is set. - Login links are built in
apps/web/app/components/auth/LoginPanel.tsx.
For anonymous chat guests, signing in with OAuth links the anonymous account; chat history in Durable Object SQLite is not migrated automatically.