skills/better-auth/SKILL.md
--- 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-c
npx skillsauth add RonanCodes/ronan-skills skills/better-authInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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. Better Auth is the DEFAULT auth pick (since the Settle build, 2026-06-07) for small SaaS and personal apps./ro:clerkis the hosted-UI consideration (drop-in components, free to 10K MAU, fastest first sign-in);/ro:workosis alt-at-scale (1M MAU free, hosted Admin Portal, B2B SSO ready). Better Auth is especially the right call 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 you specifically want hosted UI components over owning the table, reach for
/ro:clerkinstead.
/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
/ro:new-tanstack-app or /ro:migrate-to-tanstack)src/db/schema.ts, wrangler.toml with [[d1_databases]])RESEND_API_KEY in ~/.claude/.env if using --emailpnpm add better-auth
pnpm add -D @better-auth/cli
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
src/lib/auth.tsimport { 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,
});
src/routes/api/auth/$.tsTanStack 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),
});
src/lib/auth-client.tsimport { 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;
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";
pnpm drizzle-kit generate
wrangler d1 migrations apply <db-name> --local
wrangler d1 migrations apply <db-name> --remote
http://localhost:3000 (dev) or your domain<baseURL>/api/auth/callback/github# .dev.vars
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
# production
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET
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.
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;
});
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 from[email protected]. 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 [email protected] (delivers to the
// Resend account owner only — the classic "code never arrives" bug).
// Personal app → a ronanconnolly.dev sender, never simplicitylabs.io.
from: "App <[email protected]>",
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):
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.@yourdomain.com #123456 (must match the app's domain) for the strongest match.[email protected] test sender).one-time-code input attr is already right).useState/setTimeout ticking resendIn down to 0 is enough.(Source: Settle build 2026-06-07.)
--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: "[email protected]",
to: user.email,
subject: "Reset your password",
html: `<a href="${url}">Reset password</a>`,
});
},
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: "[email protected]",
to: user.email,
subject: "Verify your email",
html: `<a href="${url}">Verify email</a>`,
});
},
},
});
| 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".
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.
BETTER_AUTH_SECRET in ~/.claude/.env — it MUST be per-app so compromise of one app doesn't forge sessions for all apps..dev.vars. Verify .gitignore includes it before /ro:better-auth install exits.user/session/account/verification tables without an explicit migration plan — this skill only adds, never drops./ro:clerk is 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:workos for 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-auth to scaffold a new app with Better Auth pre-wired (default is --auth=clerk)/ro:cf-ship to ship after wiringllm-wiki-research/wiki/comparisons/auth-clerk-vs-better-auth.md, auth-three-way-deep-dive.mdtesting
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".