name: better-auth description: Wire Better Auth into a TanStack Start app. The DEFAULT auth pick (since the Settle build, 2026-06-07) for small SaaS and personal apps: identity tables in your own Postgres so an agent or API can join user to app data (agent-readable), self-issued access tokens / API keys (PAT) for app-to-app and agent access plus OAuth/OIDC for MCP clients later, passwordless email OTP via Resend as the DEFAULT sign-in method (iOS one-tap autofill via autocomplete="one-time-code", no Google Cloud Console / OAuth-app setup), with Google OAuth + passkeys as opt-in add-ons, $0 self-hosted, no vendor lock-in. Clerk (/ro:clerk) is the hosted-UI consideration; WorkOS (/ro:workos) is alt-at-scale for B2B 100K+ MAU. Use when wiring auth, login, or sign-in for a new app, the default auth, email-code or magic-link, Google sign-in, passkeys, owns-the-table, agent-readable identity, personal access tokens, custom auth flows, or EU data residency. category: auth argument-hint: [install | add-provider <github|google> | add-roles | generate-schema] [--email] allowed-tools: Bash(pnpm *) Bash(pnpx *) Bash(wrangler *) Bash(openssl *) Bash(git *) Read Write Edit
Better Auth
Wire Better Auth into a TanStack Start + Drizzle + D1 app. Code-generates schema, server config, route handler, client, and optional OAuth providers and role helpers.
When to use this vs
/ro:clerk//ro:workos. Default auth is/ro:clerk(hosted UI components, free to 10K MAU, fastest first sign-in). Alt-at-scale is/ro:workos(1M MAU free, hosted Admin Portal, B2B SSO ready). Reach for Better Auth when one of these is true:
- You need to own the
userstable for native joins, FKs, and DB-enforced row-level security against merchant-scoped data.- EU data-residency mandate that neither Clerk nor vendored AuthKit can satisfy on their standard plans.
- Fully custom auth flows (unusual onboarding, custom session shape, exotic providers) that Clerk and AuthKit do not bend to.
- Zero vendor lock-in is a hard preference. The Auth.js consolidation under the Better Auth team in 2026 makes this the safest principled-OSS pick available.
If none apply, prefer
/ro:clerk.
Usage
/ro:better-auth install # DEFAULT: passwordless email OTP via Resend (schema + server + client + one-time-code input + route)
/ro:better-auth install --password # + email/password with Resend verification
/ro:better-auth add-provider google # add Google OAuth (opt-in; needs a Google Cloud Console app)
/ro:better-auth add-provider github # add GitHub OAuth (opt-in)
/ro:better-auth add-roles # add roles plugin + helpers
/ro:better-auth generate-schema # regen Drizzle schema after config change
Prerequisites
- A TanStack Start app (
/ro:new-tanstack-appor/ro:migrate-to-tanstack) - Drizzle + D1 already wired (
src/db/schema.ts,wrangler.tomlwith[[d1_databases]]) RESEND_API_KEYin~/.claude/.envif using--email
Install flow
1. Install dependencies
pnpm add better-auth
pnpm add -D @better-auth/cli
2. Generate BETTER_AUTH_SECRET (per-app)
openssl rand -base64 32
Write it to the app's local env (NOT ~/.claude/.env — this is per-app):
# .dev.vars
BETTER_AUTH_SECRET=<generated>
BETTER_AUTH_URL=http://localhost:3000
Push to production as a wrangler secret:
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put BETTER_AUTH_URL # = https://your-app.com
3. Server config — src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
import * as schema from "@/db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite", schema }),
emailAndPassword: { enabled: true },
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL,
});
4. Route handler — src/routes/api/auth/$.ts
TanStack Start Server Route:
import { createServerFileRoute } from "@tanstack/react-start/server";
import { auth } from "@/lib/auth";
export const ServerRoute = createServerFileRoute("/api/auth/$").methods({
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
});
5. Client — src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_BETTER_AUTH_URL ?? window.location.origin,
});
export const { signIn, signUp, signOut, useSession } = authClient;
6. Generate schema
pnpx @better-auth/cli generate --config src/lib/auth.ts --output src/db/auth-schema.ts
Re-export from src/db/schema.ts:
export * from "./auth-schema";
7. Migration
pnpm drizzle-kit generate
wrangler d1 migrations apply <db-name> --local
wrangler d1 migrations apply <db-name> --remote
add-provider
GitHub
- Create OAuth app: https://github.com/settings/applications/new
- Homepage:
http://localhost:3000(dev) or your domain - Callback:
<baseURL>/api/auth/callback/github
- Homepage:
- Store secrets (per-app, NOT global):
# .dev.vars GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... # production wrangler secret put GITHUB_CLIENT_ID wrangler secret put GITHUB_CLIENT_SECRET - Patch
src/lib/auth.ts:socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, },
Same pattern. Console: https://console.cloud.google.com/apis/credentials. Callback: <baseURL>/api/auth/callback/google.
add-roles
pnpm add better-auth # plugin included
Patch src/lib/auth.ts:
import { admin } from "better-auth/plugins";
export const auth = betterAuth({
// ...existing...
plugins: [admin({ defaultRole: "user", adminRoles: ["admin"] })],
});
Re-generate schema (/ro:better-auth generate-schema) to add role column on user.
Session check helper in Server Functions:
// src/lib/auth-server.ts
import { createServerFn } from "@tanstack/react-start";
import { auth } from "@/lib/auth";
export const requireSession = createServerFn({ method: "GET" }).handler(async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw new Response("Unauthorized", { status: 401 });
return session;
});
export const requireAdmin = createServerFn({ method: "GET" }).handler(async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user.role !== "admin") throw new Response("Forbidden", { status: 403 });
return session;
});
Default sign-in: email OTP via Resend (passwordless, iOS one-tap autofill)
This is the default sign-in method install wires (canon 2026-06-07): passwordless 6-digit email code, no Google Cloud Console / OAuth-app setup, so it's the fastest login to stand up. Google/GitHub are opt-in add-provider. Needs a Resend key in ~/.claude/.env and a verified Resend sending domain.
Which Resend account? There are two in
~/.claude/.envand the bareRESEND_API_KEYis the Simplicity Labs account (simplicitylabs.io). For a personal app (anything onronanconnolly.dev, a side project, a hackathon build) useRESEND_API_KEY_RONANand send from aronanconnolly.devaddress — never the bare key, never asimplicitylabs.iosender. This trap shipped smartcart.ronanconnolly.dev sending OTP codes fromhello@simplicitylabs.io. Only use the bare key +simplicitylabs.iosender for an explicit Simplicity / Dataforce / Taskforce app. Ambiguous → ask. Full rule:/ro:env§ "Multiple accounts for one service".
Server (src/lib/auth.ts) — the emailOTP plugin, code sent via Resend:
import { emailOTP } from "better-auth/plugins";
import { Resend } from "resend";
export const auth = betterAuth({
// ...database, secret...
emailAndPassword: { enabled: false }, // passwordless
plugins: [
emailOTP({
otpLength: 6,
expiresIn: 60 * 10,
sendVerificationOnSignUp: true, // new email → code → signed in, one step
async sendVerificationOTP({ email, otp }) {
// Personal app → env.RESEND_API_KEY_RONAN (ronanconnolly.dev account);
// Simplicity/Dataforce app → env.RESEND_API_KEY. See /ro:env § multi-account.
const resend = new Resend(env.RESEND_API_KEY);
const { error } = await resend.emails.send({
// A VERIFIED domain, NOT onboarding@resend.dev (delivers to the
// Resend account owner only — the classic "code never arrives" bug).
// Personal app → a ronanconnolly.dev sender, never simplicitylabs.io.
from: "App <hello@yourdomain.com>",
to: email,
subject: "Your sign-in code",
// Code clearly near the top so iOS Mail recognises it for AutoFill.
text: `Your code is ${otp}. It expires in 10 minutes.`,
html: `<p>Your code is <strong>${otp}</strong>. Expires in 10 minutes.</p>`,
});
// Surface failures; never silently return success (a real bug we hit).
if (error) throw new Error(`Email send failed: ${error.message}`);
},
}),
],
});
Client OTP input — autocomplete="one-time-code" is the whole trick. iOS reads the code from the just-arrived Mail and offers it above the keyboard for a one-tap entry:
<input
inputMode="numeric"
autoComplete="one-time-code" // iOS QuickType one-tap autofill
pattern="[0-9]*"
maxLength={6}
/>
Reliability + autofill polish (do all of these):
- Code FIRST in the subject (
123456 is your <App> code), not buried after the app name. iOS Mail weights the subject heavily for code detection: this is the single biggest lever. - Code near the top of the body, plus Apple's domain-bound line
@yourdomain.com #123456(must match the app's domain) for the strongest match. - Send from a verified Resend domain (not the
onboarding@resend.devtest sender). - Diagnostic: if the OTP field offers an email/contact suggestion instead of the code, iOS has not found a code yet — fix the email (the
one-time-codeinput attr is already right). - Resend cooldown (ship it): on the code step, show a 2-minute countdown, then a "Resend code" button (standard OTP UX: stops spamming + tells the user when they can retry). A
useState/setTimeouttickingresendIndown to 0 is enough.
(Source: Settle build 2026-06-07.)
Email + password reset (--password flag, optional)
Requires RESEND_API_KEY (global, ~/.claude/.env).
Patch src/lib/auth.ts:
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export const auth = betterAuth({
// ...existing...
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
await resend.emails.send({
from: "no-reply@yourdomain.com",
to: user.email,
subject: "Reset your password",
html: `<a href="${url}">Reset password</a>`,
});
},
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: "no-reply@yourdomain.com",
to: user.email,
subject: "Verify your email",
html: `<a href="${url}">Verify email</a>`,
});
},
},
});
Env var summary (per-app, NOT global)
| Var | Where | How to generate |
|---|---|---|
BETTER_AUTH_SECRET |
.dev.vars + wrangler secret |
openssl rand -base64 32 |
BETTER_AUTH_URL |
.dev.vars + wrangler secret |
dev: http://localhost:3000; prod: app URL |
GITHUB_CLIENT_ID/SECRET |
.dev.vars + wrangler secret |
GitHub OAuth app |
GOOGLE_CLIENT_ID/SECRET |
.dev.vars + wrangler secret |
Google Cloud Console OAuth credentials |
The Resend key is the exception — it lives in ~/.claude/.env, not per-app .dev.vars. But there are two Resend accounts there: bare RESEND_API_KEY = Simplicity Labs (simplicitylabs.io), RESEND_API_KEY_RONAN = personal (ronanconnolly.dev). For a personal app use _RONAN and a ronanconnolly.dev sender; only use the bare key for an explicit Simplicity / Dataforce / Taskforce app. It is not "shared across all apps" — pick the account that matches the app. Full rule: /ro:env § "Multiple accounts for one service".
Phishing-resistant MFA (canon, do this by default)
Per the authentication-hardening playbook (llm-wiki-security/wiki/playbooks/authentication-hardening.md), enable a phishing-resistant factor by default. Better Auth ships a passkey (WebAuthn) plugin, add it rather than relying on password + email/SMS OTP. Passkeys/FIDO2 meet NIST 800-63B AAL2+ and are the CISA gold standard; SMS/TOTP are phishable. Also: short sessions + step-up re-auth before sensitive actions, and (for single-user/internal apps) consider gating at the edge with Cloudflare Access + WARP instead of a public login.
Safety
- Never put
BETTER_AUTH_SECRETin~/.claude/.env— it MUST be per-app so compromise of one app doesn't forge sessions for all apps. - Never commit
.dev.vars. Verify.gitignoreincludes it before/ro:better-auth installexits. - When adding an OAuth provider, verify the callback URL matches the registered app exactly — mismatches produce opaque 400s.
- Do not delete existing
user/session/account/verificationtables without an explicit migration plan — this skill only adds, never drops.
See also
/ro:clerkis the default for small SaaS (hosted UI components, free to 10K MAU, fastest first sign-in). Start there unless one of the four Better-Auth triggers above applies./ro:workosfor the alt-at-scale case (vendored auth, hosted Admin Portal, B2B SSO ready, 1M MAU free, when you do not need to own the user table)/ro:new-tanstack-app --auth=better-authto scaffold a new app with Better Auth pre-wired (default is--auth=clerk)/ro:cf-shipto ship after wiring- Better Auth docs: https://www.better-auth.com, use context7 for current syntax
- Comparison pages:
llm-wiki-research/wiki/comparisons/auth-clerk-vs-better-auth.md,auth-three-way-deep-dive.md