Skip to content
Build With SyntaxKit

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 read

On This Page

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

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

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 is a Better Auth plugin or a SyntaxKit layer on top of it.

PieceWhat ships
Email and passwordRequired 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.
OrganizationsOwner / admin / member roles. The session carries activeOrganizationId. See Organizations.
Platform admin roleSeparate from org roles. Gates /dashboard/admin and the admin oRPC namespace. See Platform Admin Role.
Cloudflare TurnstileOn when TURNSTILE_SECRET_KEY and the public site key are set.
Last-login-method memoryStored 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 uses getSession with disableCookieCache: true and bounces on a missing session or isBanned(user). Banned or just-revoked users leave on the next /dashboard navigation. The admin layout reuses the same helper.
  • API (bounded by maxAge). Direct /rpc/* and /api/* callers can see the cached payload for up to maxAge seconds 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; set maxAge: 0 to 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.com

The 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.

FlowDispatchTemplate
Verification emailoRPC auth.sendVerificationEmail. Better Auth's sendOnSignUp is off so we own dispatch after signup.packages/email/emails/email-verification.tsx
Password resetoRPC auth.requestPasswordReset + auth.resetPassword. JWT-based; decoupled from Better Auth's verification table.packages/email/emails/password-reset.tsx
Email change confirmationoRPC 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 invitationoRPC organization.inviteMember. Better Auth's sendInvitationEmail is a no-op; we send after auth.api.createInvitation returns.packages/email/emails/organization-invitation.tsx
Welcome emailRepo-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 under NODE_ENV=test so 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 enforceAbusePolicy from packages/shared. Required in production (the boot doctor refuses to start without Upstash). Auto-bypassed in non-production when Upstash is unset, so pnpm dev can 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:

  1. Pin the callback base (BETTER_AUTH_URL when set, otherwise NEXT_PUBLIC_APP_URL) to the production origin in every environment.
  2. Register the production callback with the provider.
  3. Share the same secret (at least 32 characters) across production, preview, and local.
  4. 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

Also useful: Organizations, Billing, and Setup.

Was this page helpful?

On this page