Add user accounts and sign-in to this TanStack Start app. Use when the app needs authentication, sign-in, user accounts, protected routes, or per-user data. Triggers on "auth", "login", "log in", "sign in", "sign up", "account", "users", "authentication", "protected", "who is logged in", "current user", "per-user".
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Add user accounts and sign-in to this TanStack Start app. Use when the app needs authentication, sign-in, user accounts, protected routes, or per-user data. Triggers on "auth", "login", "log in", "sign in", "sign up", "account", "users", "authentication", "protected", "who is logged in", "current user", "per-user".
metadata
{"short-description":"Auth via the Grok broker (Google, X) or local email/password — no other methods supported"}
Auth
This app authenticates users by running its ownBetter 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
(). ( → ) — 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. (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 in . Restarting the preview resets the DB.
src/lib/auth/preview.ts
/auth/popup is already handled by the template
Vite plugin
vite.config.ts
popup.server.ts
Do NOT create
src/routes/auth/popup.tsx
src/lib/auth
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/*.
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):
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):
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. null means loading OR
signed out, so never redirect on it alone.
useCurrentUserState() → { user, isPending } — for guards: wait for
isPending to clear before treating user: null as 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";
functionNavbar() {
const user = useCurrentUser(); // display only — null may just mean "loading"return (
<><span>{user?.displayName ?? "Guest"}</span><SignedOut><ahref="/login">Sign in</a></SignedOut><SignedIn><UserButton /></SignedIn></>
);
}
functionAccountPage() {
const { user, isPending } = useCurrentUserState();
if (isPending) returnnull; // session still resolvingif (!user) return<RedirectToSignIn />; // client-side Navigate — not window.locationreturn<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, not user alone — and render a same-sized skeleton.
Showing the SAME placeholder while isPending (server render + first client
paint) makes it one clean swap (skeleton → content) with no flash and no SSR
hydration mismatch. Don't return null in a slot that then grows — reserve the
space:
Guard at a layout boundary (nav / page shell), not in leaf components that
mount/unmount — useSession is 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 } = awaitimport("@/lib/auth/verify.server");
const u = awaitgetSessionUser();
return u ? { id: u.id, email: u.email } : null;
});
exportconstRoute = createRootRoute({
beforeLoad: async () => ({ sessionUser: awaitfetchSessionUser() }),
// 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 beforeLoad re-runs and the context stays fresh; call
router.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 on isPending, 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";
exportconst listTodos = createServerFn({ method: "GET" })
.middleware([authMiddleware])
.handler(async ({ context }) => {
const sql = awaitgetSql();
// 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 }`:exportconst addTodo = createServerFn({ method: "POST" })
.validator((title: string) => title.trim())
.middleware([authMiddleware])
.handler(async ({ context, data: title }) => {
if (!title) return;
const sql = awaitgetSql();
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:
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_PROVIDERS beyond them (the broker rejects an unknown idp).
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 onlysrc/lib/auth/email-password.ts:
// src/lib/auth/email-password.tsexportconst emailAndPasswordEnabled = true; // was false
Do not edit or rewrite src/lib/auth/server.ts (or any other file under
src/lib/auth/ except email-password.ts for 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.password column — no migration
needed. Then build sign-up / sign-in forms with authClient.signUp.email(...)
and authClient.signIn.email(...) from @/lib/auth/client.
Do not add emailAndPassword as 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's trustedOrigins already covers
*.grok-sandbox.com and 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.me app can't toss a
Domain=.grok.me cookie, and Better Auth rejects cross-origin /api/auth
calls.
Sibling isolation: authMiddleware rejects 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.