Google OAuth requires every callback URL to be **registered ahead of time** in Google Cloud Console. Wildcards are not allowed in standard apps. We can't register `*.myclassroomio.com` or every BYOD customer's domain.
We also can't share auth cookies across apex domains: a cookie on `api.classroomio.com` won't be sent to a request on `acme.myclassroomio.com`. Even setting `Domain=.myclassroomio.com` won't help — the OAuth callback always lands on `api.classroomio.com` per the registered URI, and that response can only set cookies for `api.classroomio.com`.
The same flow works for `app.classroomio.com` (the proxy-skip branch in `oAuthProxy` kicks in when `currentURL === productionURL`) and for BYOD domains (`learn.acme.com`) routed through Cloudflare for SaaS.
| Better Auth + `oAuthProxy` plugin registration | `packages/db/src/auth.ts` | `productionURL: CONSTANTS.BASE_URL` makes `api.classroomio.com` the canonical OAuth-callback URL. |
| Auth endpoint mount in Hono | `apps/api/src/app.ts` (`/api/auth/*` handler) | Rewrites `request.url` from `X-Forwarded-Host`/`-Proto` so the plugin sees the tenant host the user actually visited. |
| Cookies → token swap (outbound + inbound) | `apps/api/src/app.ts` (`/api/auth/*` handler) | Stashes the encrypted `cookies=…` blob in Redis under a short token, drops Location header from 10KB+ to ~150 bytes. |
| Cloudflare Worker | `apps/tenant-router/src/index.ts` | Forwards `/proxy/*` → API (stripping prefix) and `/api/auth/*` → API (as-is, no strip), everything else → dashboard. |
| Browser-side base URL | `apps/dashboard/src/lib/utils/services/api/index.ts` (`getRequestBaseUrl`) | Returns `${origin}/proxy` in the browser so auth cookies stay host-only. |
| Browser-side auth client baseURL | `apps/dashboard/src/lib/utils/services/auth/client.ts` | `${origin}/proxy/api/auth` — Better Auth's client treats a baseURL with a path as the full auth root, so we encode the post-Worker-strip path here. |
| Auto-enrollment after first signup | `packages/db/src/auth/hooks/tenant-provisioning.ts` | Resolves the tenant org from `cio-org-id` header (email signup) or `x-forwarded-host` (OAuth host). Idempotent membership insert. |
| `customSession` plugin (adds `orgRoles` to session) | `packages/db/src/auth.ts` | Loads `{ [orgId]: roleId }` via `getUserOrgRolesMap` and ships it inside the `session_data` cookie cache. Org-scoped middlewares read it from `c.get('orgRoles')` — no per-request DB query. |
Better Auth's `oAuthProxy` plugin handles cross-origin OAuth by encrypting **every Set-Cookie** the OAuth callback emits — `session_token`, `session_data`, `account_data`, `dontRememberToken` — into a single `cookies=<huge>` query parameter on the redirect URL. For ClassroomIO with `cookieCache.enabled: true` and `storeAccountCookie: true`, that blob hits ~5KB raw / ~10KB hex-encoded.
Some hop between Render and the browser (Render's edge proxy, suspect HTTP header size limit) silently drops responses with a Location header that big — the API thinks it sent a 302, but the browser never receives one. The OAuth flow hangs.
The Redis handoff sidesteps the size limit entirely:
- Encrypted cookies blob → Redis, keyed by a 32-hex-char token.
- Location header carries only the token (~150 bytes total URL).
- Tenant-host endpoint reads token from Redis, restores the cookies query param, hands the request to Better Auth as-if-original.
Single-use (GET-and-DEL on read) and 2-minute TTL keep the surface small.
## Google Cloud Console settings
You only ever need **one** OAuth client and **one** registered callback URI for unlimited tenants:
Do **not** add tenant subdomains, `*.myclassroomio.com`, or BYOD customer domains — Google rejects wildcards for standard apps and the `oAuthProxy` plugin handles the redirect to the original tenant host on its own.
If Google's consent screen warns users that the app is requesting "sensitive" or "restricted" scopes (like Drive) during sign-in, check **Google Cloud Console → OAuth consent screen → Scopes**. Sign-in only requests `email`, `profile`, `openid`. If `drive.readonly` is in the declared scopes list there, remove it. Drive access is requested separately via the in-app picker (`apps/dashboard/src/lib/utils/functions/google-drive-picker.ts`) using incremental authorization, so the consent prompt for it only appears when a user actually opens the Drive picker.
| `BETTER_AUTH_SECRET` | random 32+ char string | Encrypts the oauth-proxy cookies blob and signs sessions. |
| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Better Auth's `baseURL` and the `oAuthProxy` plugin's `productionURL`. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | from Google Cloud Console | OAuth client credentials. |
| `REDIS_URL` | Render Key Value internal URL | Required for the cookies-→-token handoff. Without it the handoff falls back to inline cookies and the OAuth flow breaks on tenant subdomains. |
| `HOST_HEADER` | `x-forwarded-host` | SvelteKit adapter-node reads the effective host from this so `event.url.host` is the tenant/admin host the user is on. |
| `PROTOCOL_HEADER` | `x-forwarded-proto` | Same, for protocol. |
| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Used at runtime only as a fallback / for `authClient`'s SSR-time URL constructor; browser code uses `${origin}/proxy` via `getRequestBaseUrl`. |
| `PRIVATE_SERVER_URL` | API's internal Render URL | Server-side dashboard → API calls bypass the public edge. |
After any change, exercise the full sequence and check the API logs:
```
[auth-handler] POST /api/auth/sign-in/social → 200
[auth-handler] GET /api/auth/callback/google → 302 location.length≈150
[auth-handler] GET /api/auth/oauth-proxy-callback → 302 (to original callbackURL)
```
If `location.length` is in the thousands again, the cookies → token swap didn't fire (check Redis connectivity, or that the response Location actually contains `cookies=`).
If the proxy-callback request never appears, the Worker's `/api/auth/*` carve-out isn't deployed (`pnpm --filter @cio/tenant-router deploy`).
If you get `state_security_mismatch`, the `oAuthProxy` plugin didn't detect the proxied flow on the callback side — most likely the `X-Forwarded-Host` rewrite in `apps/api/src/app.ts` isn't running, so `currentURL` looked like the production URL.
- **`storeAccountCookie: true` and `customSession` (with `orgRoles`) are both fine** with the Redis handoff. The size of the cookies blob no longer matters; it just has to fit in a Redis value (which has no practical ceiling here). `orgRoles` lives in the session cookie cache and is refreshed when the cookie cache expires (`session.cookieCache.maxAge`, currently 1 hour).
- **OAuth state cookie check is skipped** on the callback when the `oAuthProxy` plugin engages (`skipStateCookieCheck: true`). CSRF is still protected by the unguessable random state token stored in the DB (Better Auth's `verification` table) and the 10-minute expiry. This is the documented Better Auth mitigation.
- **Render service name swap** — `cio-api.onrender.com` is the **dashboard** service, `cio-api-vymh.onrender.com` is the **API** service. Render doesn't allow renaming. See `apps/tenant-router/wrangler.toml` for the same warning.