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
| File | Role |
|---|---|
src/lib/auth.ts | Better Auth server configuration and lazy auth proxy |
src/lib/auth-client.ts | React client, admin client plugin, signIn, signUp, signOut, useSession |
src/lib/auth-guard.ts | Server-backed requireAuth and requireAdmin route guards |
src/routes/api/auth/$.ts | GET/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.ts | Better Auth users, sessions, accounts, and verification tables |
src/routes/api/admin/users.ts | Session- 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/googleUse 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.
requireAuthprotects/dashboard,/admin,/setting, and/account(including their locale-prefixed forms) and redirects logged-out users to the localized login page withcallbackUrl.requireAdminadditionally checkssession.user.role === "admin"; other authenticated users are redirected to the localized dashboard.- The
_dashboardlayout installsbeforeLoad: requireAuth; the admin users page installsbeforeLoad: 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
/dashboardwhile logged out; confirm redirect to/auth/login. - Complete login and confirm the
callbackUrlreturns to the protected page. - Repeat from
/zh/dashboard; confirm/zhis preserved through login. - Confirm a non-admin cannot open
/admin/usersor call/api/admin/users.
