Auth
This app authenticates users by running its own
Better Auth at /api/auth/* and federating to the
shared Grok auth broker (auth.grok.me) via the genericOAuth plugin. The
broker offers the upstream sign-in methods and holds their shared secrets; this
app only holds its own per-app client id/secret and names the upstream it wants
via each provider's idp hint. This template wires Google and X; the
broker handles the actual login, so the app just renders the provider buttons.
Supported sign-in methods — use ONLY these three; nothing else is supported: Google, X, and email/password (see "Supported sign-in methods" below).
Sign-in is ON by default and REAL — including in the sandbox live preview. Build real sign-in; do NOT scaffold demo/mock/hardcoded users.
- Live preview (
*.grok-sandbox.com): the app is an embedded iframe, so sign-in opens a popup (a top-level redirect to the broker can't work inside the iframe) and federates via a baked shared preview client (src/lib/auth/preview.ts)./auth/popupis already handled by the template Vite plugin (vite.config.ts→popup.server.ts) — it 302s straight to the broker/upstream login (never paints the React app) and, on return, posts the session bearer back in a tiny HTML page. Do NOT createsrc/routes/auth/popup.tsx(a React page there shows the full app in the popup — the common failure mode). Sessions (and email/password users) persist in the app's embedded PGLite DB — the SAME DB as app data — and, since the iframe's cookies are partitioned, ride a bearer token the popup hands back; all of that lives insrc/lib/auth. Restarting the preview resets the DB. - Deployed: the deployer injects a per-app client +
DATABASE_URL, so sign-in persists identities in Postgres. - Off: only when
VITE_AUTH_ENABLED=false— then a dev user is returned (for building non-auth apps without a signed-in visitor).
Everything is preinstalled and pre-wired in src/lib/auth/ — do not
npm install anything or reach for another auth library. better-auth is the
only auth package; do NOT use @neondatabase/*, @stackframe/*, or @clerk/*.
What's pre-wired (src/lib/auth/)
| File | Use it for |
|---|---|
client.ts |
Browser client. signIn(providerId), signOut(), authEnabled, GROK_PROVIDERS. |
server.ts |
The Better Auth instance (server-only). Do not edit or rewrite. Import only from /api/auth/$. |
email-password.ts |
Only place to enable local email/password (emailAndPasswordEnabled = true). |
popup.server.ts |
Live-preview popup handler (server-only). Already wired by the Vite plugin — do not create a route for it. |
providers.ts |
GROK_PROVIDERS — the fixed broker upstream list (Google and X only; don't add others). |
use-current-user.ts |
useCurrentUser() / useCurrentUserState() React hooks. |
gates.tsx |
SignedIn, SignedOut, RedirectToSignIn, UserButton. |
middleware.ts |
authMiddleware for server functions → verified context.userId. |
verify.server.ts |
requireUserId() / getSessionUser() (server-only) for manual wiring. |
migrations/0001_auth.sql is the Better Auth schema — pre-applied, do not
edit.
Env vars — do not create a .env file
Never write a .env / .env.local / .env.example for auth (or anything
else) in this sandbox. Live preview sign-in works out of the box with zero
env configuration: the server falls back to the baked preview client in
src/lib/auth/preview.ts, derives the *.grok-sandbox.com origin per-request,
mints a process-stable session secret, and persists sessions in embedded
PGLite. Deployed apps get GROK_AUTH_* / BETTER_AUTH_* / DATABASE_URL
injected by the platform — still not something you write into a file.
Optional process-env knobs (platform / rare overrides only — do not put these in a file you create):
| Var | Where | Purpose |
|---|---|---|
VITE_AUTH_ENABLED |
client | on by default; set "false" to turn sign-in OFF (dev user). Only client-visible auth flag |
BETTER_AUTH_URL |
server | app's own public origin; unset in preview (origin is derived per-request) |
BETTER_AUTH_SECRET |
server | signs this app's own sessions (process-stable fallback in preview; survives HMR) |
GROK_AUTH_ISSUER |
server | the shared broker (defaults to https://auth.grok.me) |
GROK_AUTH_CLIENT_ID / GROK_AUTH_CLIENT_SECRET |
server | per-app client (falls back to the preview client) |
DATABASE_URL |
server | when deployed, Better Auth persists here (preview persists to the embedded PGLite — same DB as app data) |
Never expose a non-VITE_ var to the client. The preview client id/secret live
server-only in src/lib/auth/preview.ts.
Wiring (do this once)
Live-preview popup is PRE-WIRED — do not create it.
signIn opens /auth/popup; the template Vite plugin
(authPopupPlugin in vite.config.ts) serves it via popup.server.ts.
Never add src/routes/auth/popup.tsx (or any React page / client OAuth at
that path). Doing so loads the full app shell in the popup ("the app opened
instead of Google") — that is always wrong.
1. Mount Better Auth — create the catch-all API route (this is what makes
/api/auth/* work; the broker's OAuth callback lands here):
// src/routes/api/auth/$.ts
import { createFileRoute } from "@tanstack/react-router";
import { auth } from "@/lib/auth/server";
export const Route = createFileRoute("/api/auth/$")({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
});
2. Add a sign-in page — buttons that kick off the broker flow. Import from
@/lib/auth/client. authEnabled is true by default (preview + deployed),
so the buttons show and work in the live preview; the else branch only shows
when auth is explicitly disabled (VITE_AUTH_ENABLED=false):
// src/routes/login.tsx
import { createFileRoute } from "@tanstack/react-router";
import { GROK_PROVIDERS, authEnabled, signIn } from "@/lib/auth/client";
export const Route = createFileRoute("/login")({ component: Login });
function Login() {
return (
<main className="grid min-h-screen place-items-center p-6">
<div className="w-full max-w-sm space-y-3">
<h1 className="text-xl font-semibold">Sign in</h1>
{authEnabled ? (
GROK_PROVIDERS.map((p) => (
<button
key={p.providerId}
type="button"
=> signIn(p.providerId, { callbackURL: "/" })}
className="w-full cursor-pointer rounded-md border border-neutral-300 px-4 py-2 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-900"
>
Continue with {p.label}
</button>
))
) : (
<p className="text-sm text-neutral-500">Sign-in is disabled.</p>
)}
</div>
</main>
);
}
RedirectToSignIn sends signed-out users to /login by default (override with
<RedirectToSignIn to="/somewhere" />). Style the page however you like — see
the design-ui skill.
That's it — call signIn(providerId) from your sign-in buttons. The popup,
bearer-token hand-off, and request attachment are all inside src/lib/auth +
the Vite plugin; leave them alone.
Reading the user / protecting routes
@/lib/auth/use-current-user (with auth on — the default — these reflect the
REAL session, so a preview visitor is signed out until they sign in):
useCurrentUser()→AppUser | null— for display.nullmeans loading OR signed out, so never redirect on it alone.useCurrentUserState()→{ user, isPending }— for guards: wait forisPendingto clear before treatinguser: nullas signed out, or a hard reload bounces signed-in users to sign-in.
State components from @/lib/auth/gates: SignedIn, SignedOut,
RedirectToSignIn, UserButton. (When auth is disabled via
VITE_AUTH_ENABLED=false they apply dev-user semantics so a non-auth app still
renders.)
import { useCurrentUser, useCurrentUserState } from "@/lib/auth/use-current-user";
import { RedirectToSignIn, SignedIn, SignedOut, UserButton } from "@/lib/auth/gates";
function Navbar() {
const user = useCurrentUser(); // display only — null may just mean "loading"
return (
<>
<span>{user?.displayName ?? "Guest"}</span>
<SignedOut><a href="/login">Sign in</a></SignedOut>
<SignedIn><UserButton /></SignedIn>
</>
);
}
function AccountPage() {
const { user, isPending } = useCurrentUserState();
if (isPending) return null; // session still resolving
if (!user) return <RedirectToSignIn />; // client-side Navigate — not window.location
return <h1>Welcome, {user.displayName}</h1>;
}
Sign out with <UserButton /> or signOut() from @/lib/auth/client.
Session loading is the same in live preview and when deployed: wait for
isPending from useCurrentUserState() (backed by /api/auth/get-session).
The only live-preview difference is how sign-in starts (popup + bearer hand-off
instead of a full-page OAuth redirect) — not how guests vs signed-in users are
detected. Prefer <RedirectToSignIn /> (TanStack <Navigate>) over
window.location.href = "/login" so a signed-out redirect does not full-reload
the SPA.
Preventing auth flicker
useSession() resolves on the client, so a naive UI flashes signed-out →
signed-in on load. Rules:
Gate on
isPending, notuseralone — and render a same-sized skeleton. Showing the SAME placeholder whileisPending(server render + first client paint) makes it one clean swap (skeleton → content) with no flash and no SSR hydration mismatch. Don't returnnullin a slot that then grows — reserve the space:import { useCurrentUserState } from "@/lib/auth/use-current-user"; import { UserButton } from "@/lib/auth/gates"; function AuthSlot() { const { user, isPending } = useCurrentUserState(); if (isPending) return <div className="h-8 w-8 animate-pulse rounded-full bg-black/10" />; return user ? <UserButton /> : <a href="/login">Sign in</a>; }Guard at a layout boundary (nav / page shell), not in leaf components that mount/unmount —
useSessionis one shared store, so keep one stable consumer per region instead of re-gating everywhere.Zero-flash when deployed: SSR the session from the cookie. On a deployed app (and top-level navigations) the session cookie is same-origin, so the server already knows the user on the first request — resolve it in the root route and render the authed shell immediately:
// src/routes/__root.tsx (excerpt) import { createServerFn } from "@tanstack/react-start"; import { createRootRoute } from "@tanstack/react-router"; const fetchSessionUser = createServerFn({ method: "GET" }).handler(async () => { // Cookie path only — works when deployed / on top-level loads. const { getSessionUser } = await import("@/lib/auth/verify.server"); const u = await getSessionUser(); return u ? { id: u.id, email: u.email } : null; }); export const Route = createRootRoute({ beforeLoad: async () => ({ sessionUser: await fetchSessionUser() }), // Merge into the existing root — keep head() / og:image from first scaffold. // component: prefer `sessionUser` for the FIRST paint when deployed, then // `useCurrentUserState()` for live in-page updates. });Sign-in/out navigate, so
beforeLoadre-runs and the context stays fresh; callrouter.invalidate()if you change auth state without navigating.In live preview the session often rides a bearer after popup sign-in, so cookie SSR may still return null until the client
useSession()runs with the bearer attached — still gate onisPending, same as when deployed.
The template already enables Better Auth's session.cookieCache, so /get-session
answers from a cookie when one is present (no DB round-trip).
Per-user data (server-side — mandatory)
Pair auth with the DB (see the neon skill). A regular Postgres driver has full
DB access, so every server function that touches per-user data must verify
the caller and scope rows to them. Use the prewired authMiddleware: it
resolves the same-origin session to a verified context.userId (and rejects
scripted cross-site/sibling requests) — no token threading:
import { createServerFn } from "@tanstack/react-start";
import { getSql } from "@/lib/db";
import { authMiddleware } from "@/lib/auth/middleware";
export const listTodos = createServerFn({ method: "GET" })
.middleware([authMiddleware])
.handler(async ({ context }) => {
const sql = await getSql();
// Type the row shape — a server fn's return must be provably serializable.
return sql<{ id: number; title: string; done: boolean }>`select id, title, done from todos where user_id = ${context.userId} order by id desc`;
});
// Inputs go through `.validator()` (the current API); the client passes `{ data }`:
export const addTodo = createServerFn({ method: "POST" })
.validator((title: string) => title.trim())
.middleware([authMiddleware])
.handler(async ({ context, data: title }) => {
if (!title) return;
const sql = await getSql();
await sql`insert into todos (user_id, title) values (${context.userId}, ${title})`;
});
// mutations must scope writes too: `... where id = ${id} and user_id = ${context.userId}`
Call these from client code (effects, event handlers, React Query) — that's
where Sec-Fetch-Site: same-origin holds:
useEffect(() => { listTodos().then(setTodos).catch(() => setTodos([])); }, []);
Semantics: signed out → the middleware throws UnauthorizedError (message
"Unauthorized", status 401 — match it to send the visitor to sign-in), in the
live preview too (real auth). Only when auth is disabled
(VITE_AUTH_ENABLED=false) does it resolve the dev user ("dev-user") and never
throw. Keep user_id columns TEXT (Better Auth uses text ids; the disabled dev
user is 'dev-user'). Never trust a client-supplied user id — only the
middleware / requireUserId() result.
Supported sign-in methods
Use only these three — no other method is supported:
Google and X — federated through the Grok broker (pre-wired here). The broker federates these two upstreams and nothing else, so do not add entries to
GROK_PROVIDERSbeyond them (the broker rejects an unknownidp).Email + password — this app's OWN Better Auth, persisted in your database (never the broker, never mocked). Better Auth is DB-backed in BOTH modes — real Postgres when deployed and the embedded PGLite in the sandbox preview — so email/password accounts are stored and survive across requests, in preview too. It's off by default. Enable it by editing only
src/lib/auth/email-password.ts:// src/lib/auth/email-password.ts export const emailAndPasswordEnabled = true; // was falseDo not edit or rewrite
src/lib/auth/server.ts(or any other file undersrc/lib/auth/exceptemail-password.tsfor this flag). That file is pre-wired; "fixing" it by regenerating Better Auth config breaks live-preview sign-in.The pre-applied schema already has the
account.passwordcolumn — no migration needed. Then build sign-up / sign-in forms withauthClient.signUp.email(...)andauthClient.signIn.email(...)from@/lib/auth/client.Do not add
emailAndPasswordas a plugin entry (that is a syntax/type error). Do not invent a new Better Auth config.If sign-up/sign-in returns "Invalid origin", do not disable CSRF and do not edit
server.ts. The template'strustedOriginsalready covers*.grok-sandbox.comand local loopback on port 8080 (localhost/127.0.0.1/[::1]). Open the app at one of those origins (not a random host/port).
Do NOT add or use anything else: no other social / OAuth providers (GitHub, Apple, Discord, Microsoft, Facebook, …), and no magic links, passkeys, one-time codes / OTP, phone / SMS, or anonymous sign-in.
Security model (already handled — don't undo it)
- Headless broker: the app names the upstream (
idp); the broker forwards straight to Google/X. Users never see the broker. __Host-cookies +trustedOrigins: a sibling*.grok.meapp can't toss aDomain=.grok.mecookie, and Better Auth rejects cross-origin/api/authcalls.- Sibling isolation:
authMiddlewarerejects scripted cross-site/same-site requests (Fetch-Metadata), so a sibling can't ride this app's session cookie into its server functions. - The upstream Google/X tokens live only on the broker; this app only ever gets a broker-issued identity and mints its own local session.