better-auth

star 0

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.

RonanCodes By RonanCodes schedule Updated 6/7/2026

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:

  1. You need to own the users table for native joins, FKs, and DB-enforced row-level security against merchant-scoped data.
  2. EU data-residency mandate that neither Clerk nor vendored AuthKit can satisfy on their standard plans.
  3. Fully custom auth flows (unusual onboarding, custom session shape, exotic providers) that Clerk and AuthKit do not bend to.
  4. 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-app or /ro:migrate-to-tanstack)
  • Drizzle + D1 already wired (src/db/schema.ts, wrangler.toml with [[d1_databases]])
  • RESEND_API_KEY in ~/.claude/.env if 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

  1. Create OAuth app: https://github.com/settings/applications/new
    • Homepage: http://localhost:3000 (dev) or your domain
    • Callback: <baseURL>/api/auth/callback/github
  2. 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
    
  3. Patch src/lib/auth.ts:
    socialProviders: {
      github: {
        clientId: process.env.GITHUB_CLIENT_ID!,
        clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      },
    },
    

Google

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/.env and the bare RESEND_API_KEY is the Simplicity Labs account (simplicitylabs.io). For a personal app (anything on ronanconnolly.dev, a side project, a hackathon build) use RESEND_API_KEY_RONAN and send from a ronanconnolly.dev address — never the bare key, never a simplicitylabs.io sender. This trap shipped smartcart.ronanconnolly.dev sending OTP codes from hello@simplicitylabs.io. Only use the bare key + simplicitylabs.io sender 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.dev test 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-code input 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/setTimeout ticking resendIn down 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_SECRET in ~/.claude/.env — it MUST be per-app so compromise of one app doesn't forge sessions for all apps.
  • Never commit .dev.vars. Verify .gitignore includes it before /ro:better-auth install exits.
  • 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/verification tables without an explicit migration plan — this skill only adds, never drops.

See also

  • /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 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
Install via CLI
npx skills add https://github.com/RonanCodes/ronan-skills --skill better-auth
Repository Details
star Stars 0
call_split Forks 1
navigation Branch main
article Path SKILL.md
More from Creator