Authentication

Better Auth email, OAuth, sessions, roles, and route protection on TanStack Start.

Authentication is Integrated with Better Auth: email/password registration with required verification, password reset, GitHub and Google OAuth, account linking, sessions, and the admin plugin.

What it provides

  • Email/password registration with verification sent on sign-up.
  • Password-reset email and token-based reset form.
  • GitHub and Google social sign-in with account linking.
  • Client session hooks and authoritative server session checks.
  • Authenticated and admin-only route guards with locale-aware redirects.
  • Admin roles, ban fields, and a role-checked users API.

Important files

FileRole
src/lib/auth.tsBetter Auth server configuration and lazy auth proxy
src/lib/auth-client.tsReact client, admin client plugin, signIn, signUp, signOut, useSession
src/lib/auth-guard.tsServer-backed requireAuth and requireAdmin route guards
src/routes/api/auth/$.tsGET/POST catch-all mounted at /api/auth/$
src/routes/{-$locale}/auth/*Login, register, success, forgot-password, and reset-password pages
src/lib/db/schema/auth.tsBetter Auth users, sessions, accounts, and verification tables
src/routes/api/admin/users.tsSession- and role-checked admin users API

Server setup and the Workers runtime

src/lib/auth.ts configures drizzleAdapter(db, { provider: "sqlite" }) with the D1 auth schema. It enables email/password, required verification, password reset, GitHub/Google providers, account linking, admin(), and tanstackStartCookies().

The exported auth is a lazy Proxy. Better Auth is created on first property access during a request, which is when the D1 binding and Worker secrets are available. Do not replace it with a top-level betterAuth(...) singleton that captures bindings or secrets during module evaluation.

The catch-all route forwards both methods to Better Auth:

export const Route = createFileRoute("/api/auth/$")({
	server: {
		handlers: {
			GET: ({ request }) => auth.handler(request),
			POST: ({ request }) => auth.handler(request),
		},
	},
});

Email/password and email delivery

requireEmailVerification: true and sendOnSignUp: true make verification part of the default login path. The verification and forgot-password callbacks call the React Email templates through sendEmail().

Set RESEND_API_KEY and valid sender details before testing the full flow. Without a working Resend configuration, registration can reach its success page, but the verification or reset message is not delivered. See Email.

Client sessions

src/lib/auth-client.ts uses createAuthClient from better-auth/react, adds adminClient() and the inferred role field, and exports:

export const { signIn, signUp, signOut, useSession } = authClient;

Use useSession() for rendering client UI such as the user menu. It is not an authorization boundary. Server functions and API handlers must call auth.api.getSession({ headers }) before returning protected data or mutating state.

OAuth providers

Configure each enabled provider's ID and secret as described in Environment Variables. Register these callbacks with the providers:

https://app.example.com/api/auth/callback/github
https://app.example.com/api/auth/callback/google

Use http://localhost:3000 as the origin for local OAuth apps. Keep BETTER_AUTH_URL equal to that origin. If a provider is not configured, remove or hide its button in src/components/auth/OAuthButtons.tsx; the template shows both buttons by default.

OAuthButtons passes a locale-aware dashboard URL as callbackURL. The login form also preserves the route guard's callbackUrl, so a user returns to the original protected page after authentication.

Route protection

This is the TanStack Start counterpart to a Next.js proxy.ts guard. src/lib/auth-guard.ts defines a getSessionAccess createServerFn that reads request headers and calls auth.api.getSession(...) on the server.

  • requireAuth protects /dashboard, /admin, /setting, and /account (including their locale-prefixed forms) and redirects logged-out users to the localized login page with callbackUrl.
  • requireAdmin additionally checks session.user.role === "admin"; other authenticated users are redirected to the localized dashboard.
  • The _dashboard layout installs beforeLoad: requireAuth; the admin users page installs beforeLoad: requireAdmin.

A navigation guard improves routing UX, but sensitive APIs must re-check the session. For example, /api/admin/users returns 401 without a session and 403 unless the server-side role is admin. Follow the same pattern for every new protected handler.

Roles and admin access

The admin plugin adds role, banned, banReason, and banExpires fields to the user schema. The default role is user. Promote an account through an audited administrative process or a controlled database operation—never by trusting a role sent from the browser.

Navigation visibility is only presentation. requireAdmin and each admin API's own role check are the authorization boundaries.

Verify

  • Register with email/password and confirm a D1 user row is created.
  • Open the verification link and log in.
  • Request a password reset and complete it with the emailed token.
  • Sign in with each configured OAuth provider and confirm account linking.
  • Log out and confirm the session UI updates.
  • Visit /dashboard while logged out; confirm redirect to /auth/login.
  • Complete login and confirm the callbackUrl returns to the protected page.
  • Repeat from /zh/dashboard; confirm /zh is preserved through login.
  • Confirm a non-admin cannot open /admin/users or call /api/admin/users.

On this page