Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Build With SyntaxKit

Authentication

Better Auth for email, password, OAuth, and 2FA.

Last updated on

9 min read

Authentication 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

Sign-in lifecycle: email/password, OAuth, and passkey surfaces converge on /api/auth/[...all] and proceed through Better Auth, abuse checks, an optional 2FA challenge, the active-org session hook, and a cookie set

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

index.tsPublic re-exports: auth, authClient, signIn/signUp/signOut, useSession, getSessionCookie
server.tsBetter Auth server config; plugin composition; OAuth, captcha, and rate-limit gating
client.tsBrowser-side Better Auth client with the matching plugins
hooks.tsafterUserCreate, beforeSessionCreate / beforeSessionUpdate, beforeUserDelete
permissions.tsAccess control statements and the owner / admin / member role definitions
utils.tsHelpers like isAdmin and isBanned
bootstrap-admin.tsOne-shot promotion of the first admin

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 logo

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.tsx and apps/web/app/dashboard/admin/layout.tsx go through loadDashboardSession (apps/web/lib/dashboard-session.ts), which calls auth.api.getSession with query: { disableCookieCache: true } and short-circuits to the localized login path on a missing session or isBanned(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/admin immediately.
  • API surface (bounded by maxAge). Direct callers of /rpc/* and /api/* keep seeing the cached payload for at most maxAge seconds 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; setting maxAge: 0 disables 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.com

The 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 under NODE_ENV=test so 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 enforceAbusePolicy from packages/shared. Required in production (the boot doctor refuses to start without Upstash); auto-bypassed in non-production when Upstash isn't configured so pnpm dev can 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

Was this page helpful?

On this page