Authentication
Better Auth for email, password, OAuth, and 2FA.
Last updated on
9 min readAuthentication lives in packages/auth, built on Better Auth. SyntaxKit ships the integration: a layered set of plugins (email/password, OAuth, passkeys, 2FA, organizations, platform admin role), gated captcha, two layers of abuse throttling on auth-email flows, and a session shape that already knows about the user's active organization.
Identity models live in auth.generated.prisma, regenerated via pnpm auth:generate, so Better Auth and the rest of the data layer stay in sync.
How sign-in flows
Three surfaces converge on one Better Auth route, then 2FA and the active-org hook.
What's wired in
The eight auth pieces that ship enabled out of the box.
How sessions work
The cookie-based session shape, the cache, and ban enforcement.
Add an OAuth provider
The end-to-end walkthrough for wiring a new provider.
How Sign-In Flows
Three sign-in surfaces converge on a single Next route, /api/auth/[...all], that Better Auth owns end-to-end. Once the credentials clear the rate-limit and captcha layers, a 2FA challenge intervenes only when the user is enrolled. The beforeSessionCreate hook then resolves which organization to make active before the session cookie is issued, so by the time the dashboard renders, the session already knows the user's active org.
The diagram source lives at apps/docs/diagrams/sign-in-flow.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.
Package Layout
What's Wired In
Eight pieces ship enabled. Each one is either a Better Auth plugin or a SyntaxKit-side decision layered on top.
Email and password
Required email verification on sign-up. The credential provider is on by default; the synthetic-user shape in server.ts makes the test/admin plugins behave correctly with email/password accounts.
OAuth (GitHub, Google)
Both providers are pre-wired and gracefully omitted at server boot when their env vars are missing. The matching login button stays visible and disabled in the UI.
Passkeys (WebAuthn)
Better Auth's passkey plugin plus @better-auth/passkey on the server. Users enroll from personal settings; the Passkey model is owned by the plugin.
Two-factor auth (TOTP)
Backup codes included. Per-route rate limits explicitly bracket the TOTP and backup-code endpoints.
Organizations
Multi-tenant organizations with owner / admin / member roles. The session carries activeOrganizationId. Deep coverage on the Organizations page.
Platform admin role
Distinct from org roles. Controls access to /dashboard/admin and the admin oRPC namespace.
Cloudflare Turnstile
Conditionally enabled when TURNSTILE_SECRET_KEY and the public site key are set. The captcha plugin attaches to the relevant auth endpoints.
Last-login-method memory
Persisted on User so the login form can hint at how the user signed in last time.
How Sessions Work
Sessions are cookie-based, set by Better Auth, and opaque to your code. The session shape this repo extends includes activeOrganizationId and impersonatedBy on the session, plus role, twoFactorEnabled, ban state, lastActiveOrganizationId, and lastLoginMethod on the user.
activeOrganizationId is populated by the beforeSessionCreate hook when a session is created or refreshed: it prefers the user's lastActiveOrganizationId if they're still a member, otherwise it falls back to the first organization they belong to. When the user switches orgs at runtime via auth.api.setActiveOrganization, the beforeSessionUpdate hook writes the new id back to User.lastActiveOrganizationId so the next session picks it up automatically.
Reading Sessions On The Server
Use auth.api.getSession({ headers }) directly 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");Inside oRPC handlers, the authorized procedure base handles this for you and adds session and user to the procedure context; see the API page for the middleware chain.
apps/web/proxy.ts does a lightweight cookie-presence check via getSessionCookie(request) before requests even reach the route layer. That is what produces the redirect to /auth/login for unauthenticated traffic to /dashboard and /api-reference.
Session Cache And Ban Enforcement
Better Auth caches sessions in a signed cookie so most authenticated requests skip the database round-trip. The kit configures this in packages/auth/src/server.ts:
session: {
cookieCache: {
enabled: true,
maxAge: 60,
},
}maxAge is the trade-off knob between session-check performance and revocation latency. With caching enabled, auth.api.getSession returns the signed payload without consulting the database until the cache expires. That means auth.api.banUser can revoke server-side sessions immediately, but the still-valid signed cookie continues to satisfy auth.api.getSession until maxAge elapses.
The kit closes this window on two surfaces:
- UI surface (immediate).
apps/web/app/dashboard/layout.tsxandapps/web/app/dashboard/admin/layout.tsxgo throughloadDashboardSession(apps/web/lib/dashboard-session.ts), which callsauth.api.getSessionwithquery: { disableCookieCache: true }and short-circuits to the localized login path on a missing session orisBanned(user). Banned users and users whose sessions were just revoked are bounced on the very next /dashboard navigation, regardless of cookie cache state. The same helper is reused by the admin layout so demoted admins are bounced from/dashboard/adminimmediately. - API surface (bounded by
maxAge). Direct callers of/rpc/*and/api/*keep seeing the cached payload for at mostmaxAgeseconds after the ban. The default of 60 seconds is Better Auth's documented floor for workloads where "immediate session revocation is critical." Lowering it tightens the window at the cost of more DB lookups; raising it loosens revocation latency in exchange for fewer lookups; settingmaxAge: 0disables the cache entirely so every authenticated request validates against the database.
isBanned(user) lives in packages/auth/src/utils.ts alongside isAdmin(role) and is the canonical check for ban state, including banExpires handling. Use it anywhere you need to short-circuit a still-cached session.
Reading Sessions In The Browser
Client components use the useSession hook re-exported 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 the mutations: signIn.email, signIn.social, signUp.email, signOut, plus passkey and 2FA actions on their respective sub-namespaces.
Configuring OAuth Providers
GitHub and Google OAuth ship by default. The first-time setup walkthroughs (create the OAuth app on each provider, set the callback URL, copy credentials, paste env vars) live on Setup: Authentication.
Both providers degrade gracefully when their env vars are missing: the server skips registering the provider, and the matching login button is rendered disabled rather than hidden.
For providers Better Auth supports beyond these two, see Adding A New OAuth Provider below.
Two-Factor Auth
TOTP-based, with backup codes, enrolled per user from the dashboard's personal settings. When a user has 2FA enrolled, the sign-in flow redirects to /auth/2fa-verify after the credential check (visible in the diagram above). The Better Auth rate-limit config explicitly brackets the TOTP and backup-code endpoints so brute-force attempts hit a wall quickly.
Passkeys
WebAuthn via the passkey plugin and @better-auth/passkey on the server. Users enroll from personal settings; once enrolled, the sign-in form offers a passkey option that uses the platform authenticator (Touch ID, Windows Hello, hardware keys). The Passkey model lives in auth.generated.prisma and is owned entirely by the plugin.
Platform Admin Role
Distinct from organization roles. The admin plugin adds a role field and ban state to User, plus an impersonatedBy field on Session. isAdmin(role) is the canonical check; the requireAdmin middleware in API gates every procedure under the admin namespace, and admin-only pages like /dashboard/admin use the same helper at the layout level.
Bootstrap the first admin with the helper script:
pnpm admin:bootstrap --email you@example.comThe script promotes a user only when no admin exists yet. Once the first admin is in place, subsequent admins are promoted through the admin.setRole API procedure from the admin panel.
Email Flows
Five auth-related emails ship out of the box. The first four are user-initiated and dispatched synchronously through oRPC mutations so a provider outage or any send failure surfaces to the UI as a real error (with an inline banner on the post-signup "check inbox" screen). The Better Auth hooks remain in place as best-effort fallbacks for direct API consumers but are no longer the primary dispatch path for the UI flows. The welcome email is a SyntaxKit-side hook; failures there are intentionally logged and ignored.
Verification email
oRPC auth.sendVerificationEmail. BA's sendOnSignUp is disabled so we own the dispatch after signup completes. Template: packages/email/emails/email-verification.tsx.
Password reset
oRPC auth.requestPasswordReset + auth.resetPassword. JWT-based, fully decoupled from BA's verification table. Template: packages/email/emails/password-reset.tsx.
Email change confirmation
oRPC user.updateEmail. Mints the same BA-compatible JWT and sends synchronously; click-through is handled by BA's existing verify-email route. Template: packages/email/emails/email-change.tsx.
Organization invitation
oRPC organization.inviteMember. BA's sendInvitationEmail hook is a no-op. We dispatch after auth.api.createInvitation returns. Template: packages/email/emails/organization-invitation.tsx.
Welcome email
Repo-side afterUserCreate hook (not a Better Auth callback). Fire-and-forget; failures are logged. Template: packages/email/emails/welcome.tsx.
See Email → Why The UI Flows Go Through oRPC Instead Of BA Hooks for the architecture rationale and the failure-sentinel mapping.
Abuse Protection
Two layers, on purpose.
- Built-in Better Auth rate limit. Per-route limits configured directly 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). Loose default for everything else (100 per minute). Limits widen automatically underNODE_ENV=testso Vitest and Playwright don't fight the throttle; there is no opt-out env var, so the production-bound limits cannot be neutered by a leaky deploy template. - Upstash-backed email throttling. Layered on top of the 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 isn't configured sopnpm devcan send verification emails on a fresh checkout. See Security → Abuse Protection for the full posture.
The two layers cover different shapes of abuse: rate-limit defends each endpoint, email throttling defends the user/recipient regardless of which endpoint the attacker hits.
Adding A New OAuth Provider
Pick a Better Auth-supported provider
Better Auth ships first-class support for Discord, Apple, Microsoft, GitLab, and several others. Pick from the upstream list before going custom.
Register an OAuth app with the provider
Create the application 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 so contributors see them.
Extend getOAuthProviderConfig
Add a branch in getOAuthProviderConfig (in packages/auth/src/server.ts) that returns the provider's config block when those env vars are present.
Register the provider
Add the provider to the socialProviders map in the betterAuth({...}) call so it spreads in only when configured. The existing GitHub and Google entries are good models.
Wire the UI
Add a button to apps/web/components/auth/oauth-buttons.tsx. Mirror the existing GitHub/Google branches so the button stays visible and disabled when env keys are missing. That disabled-state messaging is one of the things that makes the kit feel finished out of the box.
Account linking is on by default. A user signing in with a new provider that matches an existing email gets the providers linked, not a duplicate account.
Optional: extend setup capabilities
To surface the new provider in pnpm setup:doctor reports 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.
Organizations
The multi-tenant story: org membership, invitations, and seat limits.
Billing
Stripe state hangs off the active organization on the session.
Setup
The full env-var matrix for every integration referenced on this page.
