Authentication
Better Auth in packages/auth for email/password, OAuth, passkeys, and 2FA. Organizations, platform admin, Turnstile, and abuse throttling ship on top. Sessions carry the active organization. Identity models live in auth.generated.prisma.
Last updated on
9 min readOn This Page
How Sign-In Flows
Email/password, OAuth, and passkeys all hit one Next route: /api/auth/[...all]. Better Auth owns that route end to end.
After rate-limit and captcha checks pass, enrolled users get a 2FA challenge. Then the beforeSessionCreate hook picks the active organization and Better Auth sets the session cookie. By the time the dashboard renders, the session already knows which org is active.
Package Layout
What's Wired In
Eight pieces ship enabled. Each is a Better Auth plugin or a SyntaxKit layer on top of it.
| Piece | What ships |
|---|---|
| Email and password | Required email verification on sign-up. On by default. |
| OAuth (GitHub, Google) | Pre-wired. Skipped at boot when env vars are missing; the login button stays visible and disabled. |
| Passkeys (WebAuthn) | Enroll from personal settings; sign-in then offers a passkey option. The Passkey model lives in auth.generated.prisma. |
| Two-factor auth (TOTP) | Backup codes included. Enrolled users hit /auth/2fa-verify after the credential check. |
| Organizations | Owner / admin / member roles. The session carries activeOrganizationId. See Organizations. |
| Platform admin role | Separate from org roles. Gates /dashboard/admin and the admin oRPC namespace. See Platform Admin Role. |
| Cloudflare Turnstile | On when TURNSTILE_SECRET_KEY and the public site key are set. |
| Last-login-method memory | Stored on User so the login form can hint at the last method used. |
How Sessions Work
Sessions are cookie-based and set by Better Auth. Your code treats them as opaque.
The kit extends the session with activeOrganizationId and impersonatedBy. On the user it adds role, twoFactorEnabled, ban state, lastActiveOrganizationId, and lastLoginMethod.
beforeSessionCreate fills activeOrganizationId when a session is created or refreshed. It prefers lastActiveOrganizationId if the user is still a member of that org; otherwise it uses the first org they belong to. When the user switches orgs with auth.api.setActiveOrganization, beforeSessionUpdate writes the new id to User.lastActiveOrganizationId so the next session picks it up.
Reading sessions on the server
Call auth.api.getSession({ headers }) in any React Server Component or route handler:
import { auth } from "@syntaxkit/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/auth/login");In oRPC handlers, the authorized procedure base does this for you and adds session and user to context. See API.
apps/web/proxy.ts checks for a session cookie with getSessionCookie(request) before the request reaches the route. That redirect sends unauthenticated traffic away from /dashboard and /api-reference.
Session cache and ban enforcement
Better Auth caches sessions in a signed cookie so most authenticated requests skip the database. Config lives in packages/auth/src/server.ts:
session: {
cookieCache: {
enabled: true,
maxAge: 60,
},
}maxAge is the trade-off between fewer DB lookups and how fast a ban or revoke takes effect. While the cache is valid, auth.api.getSession returns the signed payload without hitting the database. auth.api.banUser can revoke server-side sessions immediately, but the cookie still satisfies getSession until maxAge elapses.
The kit closes that window on two surfaces:
- UI (immediate). Dashboard layouts call
loadDashboardSession(apps/web/lib/dashboard-session.ts), which usesgetSessionwithdisableCookieCache: trueand bounces on a missing session orisBanned(user). Banned or just-revoked users leave on the next/dashboardnavigation. The admin layout reuses the same helper. - API (bounded by
maxAge). Direct/rpc/*and/api/*callers can see the cached payload for up tomaxAgeseconds after a ban. The default of 60 seconds is Better Auth's floor when fast revocation matters. Lower it for a tighter window (more DB lookups); raise it for fewer lookups; setmaxAge: 0to disable the cache.
isBanned(user) in packages/auth/src/utils.ts is the canonical ban check, including banExpires. Use it anywhere a cached session might still look valid.
Reading sessions in the browser
Client components use useSession from @syntaxkit/auth:
"use client";
import { useSession, signOut } from "@syntaxkit/auth";
export function HeaderUser() {
const { data: session, isPending } = useSession();
if (isPending) return null;
return session
? <button onClick={() => signOut()}>Sign out</button>
: <a href="/auth/login">Sign in</a>;
}The same client exposes signIn.email, signIn.social, signUp.email, signOut, plus passkey and 2FA actions on their sub-namespaces.
Platform Admin Role
Platform admin is separate from organization roles. The Better Auth admin plugin adds role and ban state on User, plus impersonatedBy on Session.
isAdmin(role) is the canonical check. The requireAdmin middleware in API gates every procedure under the admin namespace. Admin-only pages like /dashboard/admin use the same helper at the layout level.
Bootstrap the first admin:
pnpm admin:bootstrap --email you@example.comThe script promotes a user only when no admin exists yet. After that, promote more admins with admin.setRole from the admin panel.
Email Flows
Five auth-related emails ship out of the box.
The first four are user-initiated and sent synchronously through oRPC mutations. A provider outage or send failure shows up in the UI (including an inline banner on the post-signup check-inbox screen). Better Auth hooks stay as best-effort fallbacks for direct API consumers; they are not the primary path for UI flows.
The welcome email is a SyntaxKit afterUserCreate hook. Failures there are logged and ignored on purpose.
| Flow | Dispatch | Template |
|---|---|---|
| Verification email | oRPC auth.sendVerificationEmail. Better Auth's sendOnSignUp is off so we own dispatch after signup. | packages/email/emails/email-verification.tsx |
| Password reset | oRPC auth.requestPasswordReset + auth.resetPassword. JWT-based; decoupled from Better Auth's verification table. | packages/email/emails/password-reset.tsx |
| Email change confirmation | oRPC user.updateEmail. Mints a Better Auth-compatible JWT and sends it; click-through uses Better Auth's verify-email route. | packages/email/emails/email-change.tsx |
| Organization invitation | oRPC organization.inviteMember. Better Auth's sendInvitationEmail is a no-op; we send after auth.api.createInvitation returns. | packages/email/emails/organization-invitation.tsx |
| Welcome email | Repo-side afterUserCreate hook. Fire-and-forget; failures are logged. | packages/email/emails/welcome.tsx |
See Email → Why The UI Flows Go Through oRPC Instead Of BA Hooks for the architecture rationale and failure-sentinel mapping.
Abuse Protection
Two layers, on purpose.
- Better Auth rate limits. Per-route limits in
betterAuth({ rateLimit: ... }). Tight on/sign-in/email(5 per 10s),/sign-up/email(5 per minute),/request-password-reset(3 per minute), and the 2FA verify routes (3 per 10s). Default for everything else: 100 per minute. Limits widen underNODE_ENV=testso Vitest and Playwright do not fight the throttle. There is no opt-out env var, so production limits cannot be turned off by a leaky deploy template. - Upstash email throttling. Layered on auth-email send paths via
enforceAbusePolicyfrompackages/shared. Required in production (the boot doctor refuses to start without Upstash). Auto-bypassed in non-production when Upstash is unset, sopnpm devcan send verification emails on a fresh checkout. See Security → Abuse Protection.
Rate limits defend each endpoint. Email throttling defends the user/recipient no matter which endpoint the attacker hits.
Configuring OAuth Providers
GitHub and Google OAuth ship by default. Use Setup: GitHub OAuth or Setup: Google OAuth to create the provider app, register callback URLs, and set credentials.
Better Auth uses BETTER_AUTH_URL as the callback base when set; otherwise it falls back to NEXT_PUBLIC_APP_URL. Local and production setups use the origins shown in Setup.
For preview deployments, optional OAUTH_PROXY_SECRET routes OAuth through production:
- Pin the callback base (
BETTER_AUTH_URLwhen set, otherwiseNEXT_PUBLIC_APP_URL) to the production origin in every environment. - Register the production callback with the provider.
- Share the same secret (at least 32 characters) across production, preview, and local.
- Allow preview or local return origins through
AUTH_TRUSTED_ORIGINS.
This proxy secret is distinct from BETTER_AUTH_SECRET. See apps/web/.env.example for the full variable contract.
When a provider's env vars are missing, the server skips registering it and the matching login button stays visible but disabled.
For providers beyond these two, see Adding A New OAuth Provider.
Adding A New OAuth Provider
Pick a Better Auth-supported provider
Better Auth has first-class support for Discord, Apple, Microsoft, GitLab, and others. Pick from the upstream list before going custom.
Register an OAuth app with the provider
Create the app in the provider's developer console. Copy the client ID and secret. Set the callback URL to <NEXT_PUBLIC_APP_URL>/api/auth/callback/<provider>.
Add env vars
Add <PROVIDER>_CLIENT_ID and <PROVIDER>_CLIENT_SECRET to apps/web/.env, and mirror them in apps/web/.env.example.
Extend getOAuthProviderConfig
Add a branch in getOAuthProviderConfig (packages/auth/src/server.ts) that returns the provider config when those env vars are present.
Register the provider
Add the provider to the socialProviders map in betterAuth({...}) so it spreads in only when configured. Mirror the GitHub and Google entries.
Wire the UI
Add a button to apps/web/components/auth/oauth-buttons.tsx. Mirror the GitHub/Google branches so the button stays visible and disabled when env keys are missing.
Account linking is on by default. Signing in with a new provider that matches an existing email links the providers instead of creating a duplicate account.
Optional: extend setup capabilities
To surface the provider in pnpm setup:doctor and the setup capability table, extend isOAuthProviderEnabled and the per-provider env name lists in packages/shared/src/setup.ts. Skippable for personal projects; recommended if you ship the kit to a team.
Where To Go Next
Database
The auth.generated.prisma models that back every plugin enabled here.
API
The authorized and requireAdmin middleware that consumes the session shape this page describes.
Also useful: Organizations, Billing, and Setup.
API
One oRPC router in packages/api, mounted at /rpc. Same router and Zod schemas feed the admin-only OpenAPI reference at /api-reference. Middleware, namespaces, React callers, streaming chat, and how to add a procedure.
Organizations
Multi-tenancy via Better Auth organizations in packages/auth. Personal orgs on sign-up, invites with owner / admin / member roles, the active org on the session, and how to scope product data to one org.
