Skip to content

Auth

Next.js boilerplate with Better Auth

Own your users table. Passwords, magic links, Google, GitHub and Microsoft, all in your database.

Self-hosted, TypeScript-native authentication that lives in your own database. Email and password, magic links, and Google, GitHub and Microsoft sign-in (each on when its keys are set), with sign-up, password reset, account settings, a role column the admin panel can trust, and server-side session helpers with no vendor session service in the request path.

What Better Auth adds to the agent layer: 2 rules · 2 skills · 10 solution docs · 1 MCP server

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick Better Auth?

Pick it if

Teams who want the user table in their own database, joinable with their own data. No per-MAU bill and no third-party outage in the login path.

Watch out for

  • You own the security surface. Nobody rotates your signing secret, patches your session logic or answers a pen-test questionnaire for you.
  • No hosted UI. Sign-in and sign-up screens are yours to build and style, which is why this battery ships real ones instead of a redirect.
Show 4 more
  • Email deliverability is your problem. A reset or magic link that lands in spam is an outage for that person.
  • Each OAuth provider is an app you register and keep alive yourself: callback URLs per environment, and a Microsoft client secret that expires.
  • Enterprise features other vendors sell as a plan tier (SAML, SCIM, audit log) are plugins or your own code here.
  • Upgrades are yours to run. New Better Auth minors sometimes add columns, so regenerating the schema is part of every upgrade.

What it costs

Free and open source (MIT). You pay only for your own Postgres and the sign-in emails you send. Google, GitHub and Microsoft sign-in are free.

Prices change. Check with Better Auth before you commit.

registry/tested.yaml

Tested with Better Auth

Each pair was installed, typechecked, linted, built and booted together.

Database
NeonSupabase
Admin panel
Admin panel
Error tracking
Sentry
Customer support
Crisp

What it adds

What Better Auth adds to the repo

Read straight from the better-auth manifest, so it is exactly what lands in your repo.

Environment variables

  • BETTER_AUTH_SECRETRequired

    Signing key for session cookies, emailed links and the cookie cache. Rotating it logs everyone out.

    Where to get it
    Generate one with bun run auth:secret, or openssl rand -base64 32. verify rejects this placeholder.
    Placeholder
    replace-me
  • BETTER_AUTH_URLRequired

    Absolute origin the app is served from. Emailed links, OAuth callback URLs and the trusted-origin (CSRF) check are all built from it.

    Placeholder
    http://localhost:3000
  • VERCEL_URLOptional

    Host of the current preview deployment, added to trustedOrigins so sign-in works on a preview URL.

    Where to get it
    Injected by Vercel automatically. Never set it by hand, and do not expect it anywhere else. Off Vercel it is simply absent, and only BETTER_AUTH_URL is trusted, which is the correct behaviour for a single-origin deployment.
    Placeholder
  • GOOGLE_CLIENT_IDOptional

    Google OAuth client ID. With GOOGLE_CLIENT_SECRET it turns on "Continue with Google".

    Where to get it
    Google Cloud console, Google Auth Platform, Clients, Create client, Web application (https://console.cloud.google.com/auth/clients). Authorized redirect URI: ${BETTER_AUTH_URL}/api/auth/callback/google, so http://localhost:3000/api/auth/callback/google locally.
    Placeholder
  • GOOGLE_CLIENT_SECRETOptional

    Google OAuth client secret, shown once when you create the client.

    Where to get it
    Same client as GOOGLE_CLIENT_ID. Leave both empty to hide the Google button.
    Placeholder
  • GITHUB_CLIENT_IDOptional

    GitHub OAuth app client ID. With GITHUB_CLIENT_SECRET it turns on "Continue with GitHub".

    Where to get it
    GitHub, Settings, Developer settings, OAuth Apps, New OAuth App (https://github.com/settings/developers). Authorization callback URL: ${BETTER_AUTH_URL}/api/auth/callback/github. A GitHub OAuth app has one callback URL, so make one app per environment.
    Placeholder
  • GITHUB_CLIENT_SECRETOptional

    GitHub OAuth app client secret. Generate it on the app's page.

    Where to get it
    Same app as GITHUB_CLIENT_ID. Leave both empty to hide the GitHub button.
    Placeholder
  • MICROSOFT_CLIENT_IDOptional

    Microsoft Entra application (client) ID. With MICROSOFT_CLIENT_SECRET it turns on "Continue with Microsoft".

    Where to get it
    Microsoft Entra admin center, App registrations, New registration (https://entra.microsoft.com). Supported accounts: any organizational directory and personal Microsoft accounts. Redirect URI, platform Web: ${BETTER_AUTH_URL}/api/auth/callback/microsoft.
    Placeholder
  • MICROSOFT_CLIENT_SECRETOptional

    Microsoft client secret VALUE (not the secret ID), from Certificates & secrets. It expires; note the date.

    Where to get it
    Same registration as MICROSOFT_CLIENT_ID. Leave both empty to hide the Microsoft button.
    Placeholder
  • MICROSOFT_TENANT_IDOptional

    Which Microsoft accounts may sign in. Empty means common (work, school and personal).

    Where to get it
    common, organizations (work and school only), consumers (personal only), or your directory (tenant) ID to allow one organisation.
    Placeholder

Dependencies

  • better-auth~1.7.5
  • server-only^0.0.1

Scripts

  • bun run auth:make-admin

    bun --conditions=react-server scripts/auth/make-admin.ts

  • bun run auth:secret

    bunx auth@1.7 secret

  • bun run auth:seed

    bun --conditions=react-server scripts/auth/seed.ts

MCP server

  • better-auth

    URL
    https://mcp.better-auth.com/mcp

Files it writes

62 files, at these exact paths.

  • scripts/2 files
    • auth/2 files
      • make-admin.ts
      • seed.ts
  • src/50 files
    • app/7 files
      • (better-auth)/6 files
        • banned/1 file
          • page.tsx
        • forgot-password/1 file
          • page.tsx
        • reset-password/1 file
          • page.tsx
        • sign-in/1 file
          • page.tsx
        • sign-up/1 file
          • page.tsx
        • layout.tsx
      • api/1 file
        • auth/1 file
          • [...all]/1 file
            • route.ts
    • components/22 files
      • auth/22 files
        • settings/6 files
          • connected-accounts-card.tsx
          • email-card.tsx
          • password-card.tsx
          • profile-card.tsx
          • reauthenticate-alert.tsx
          • sessions-card.tsx
        • account-settings.tsx
        • auth-card.tsx
        • auth-setup-notice.tsx
        • check-inbox.tsx
        • focus-first-error.ts
        • forgot-password-form.tsx
        • header-actions.tsx
        • magic-link-form.tsx
        • oauth-buttons.tsx
        • password-input.tsx
        • provider-icons.tsx
        • reset-password-form.tsx
        • session-provider.tsx
        • sign-in-form.tsx
        • sign-out-button.tsx
        • sign-up-form.tsx
    • lib/21 files
      • auth/21 files
        • action-limit.test.ts
        • action-limit.ts
        • actions.ts
        • auth.ts
        • client.ts
        • endpoint-guard.test.ts
        • endpoint-guard.ts
        • errors.ts
        • guards.test.ts
        • list-sessions.test.ts
        • list-sessions.ts
        • policy.ts
        • providers.test.ts
        • providers.ts
        • roles.ts
        • schemas.ts
        • secret.ts
        • session.ts
        • setup.ts
        • user-agent.test.ts
        • user-agent.ts
  • tests/4 files
    • e2e/4 files
      • auth.setup.ts
      • auth.spec.ts
      • auth.ts
      • outbox.ts
  • variants/6 files
    • orm-drizzle/3 files
      • slots/1 file
        • db-schema.ts
      • src/2 files
        • lib/2 files
          • auth/2 files
            • adapter.ts
            • schema.ts
    • orm-prisma/3 files
      • slots/1 file
        • prisma-models.prisma
      • src/2 files
        • lib/2 files
          • auth/2 files
            • adapter.ts
            • schema.ts

Stack slots it fills

The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.

  • @slot bare-route-groups
  • @slot env-required
  • @slot header-actions
  • @slot proxy-handlers
  • @slot verify-checks

The differentiator

What Better Auth teaches your agent

Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.

Rules (2)

Loaded when the agent opens a matching file.

The auth server boundary and sign-in methods

Loads onsrc/lib/auth/**src/app/api/auth/**src/app/(better-auth)/**src/components/auth/**.claude/rules/auth-server-boundary.md
One config, server-only importers

src/lib/auth/auth.ts constructs the only betterAuth() instance in this repo. Only server code imports it:

  • src/app/api/auth/[...all]/route.ts: the handler
  • src/lib/auth/session.ts: the server-side session helpers
  • src/lib/auth/actions.ts: the server actions behind Settings (profile, password, connected accounts, sessions)
  • src/components/auth/account-settings.tsx: server components that read the account's sign-in methods and sessions
  • scripts/auth/*: terminal scripts, run through tsServer

Anything else imports session.ts (server), actions.ts (a server action called from a client component) or client.ts (browser). Importing auth.ts from a client component drags the ORM, the mailer and BETTER_AUTH_SECRET into the browser bundle.

Never construct a second betterAuth(): not for a script, not for a test, not for "just this one job". Two instances mean two configurations, and the one that signed a cookie is not necessarily the one that verifies it.

The endpoint guard runs before every endpoint

auth.ts registers endpointGuard, a plugin whose hooks.before runs for every Better Auth endpoint, over HTTP and through auth.api.*. The rules live in src/lib/auth/endpoint-guard.ts (pure, tested in endpoint-guard.test.ts against the full endpoint list of the installed version):

  • An impersonation session is read-only at the API. It may call IMPERSONATION_ALLOWED_PATHS (get-session, list-accounts, sign-out, stop-impersonating) and nothing else: 403 IMPERSONATION_READ_ONLY. It is an allowlist, so an endpoint a new plugin or an upgrade adds is refused until you add it on purpose. Never add /list-sessions (it returns every session's token) or anything that returns provider tokens.
  • Adding a way in needs a recent sign-in. setPassword, /link-social and /change-email need a session created within RECENT_SIGN_IN_MINUTES (src/lib/auth/policy.ts): 403 RECENT_SIGN_IN_REQUIRED. The UI answers it with ReauthenticateAlert ("Confirm it's you": sign out, sign in with ?next=/settings/security, come back).

Rules for changing it:

  • Keep endpointGuard after every other plugin and before nextCookies(). A plugin that signs a request in from a header does it in its own hook, and the guard must see the result.
  • Match on the declared route (ctx.path), never on the request URL. A server-only endpoint has no route: match it by operationId in RECENT_SIGN_IN_OPERATIONS, and keep the matching check in its server action.
  • A new endpoint that adds a sign-in method, a credential or an address goes in RECENT_SIGN_IN_PATHS. A new endpoint an impersonating admin genuinely needs goes in IMPERSONATION_ALLOWED_PATHS only if it cannot change the account and returns no token. Update the endpoint list in the test either way.

See docs/solutions/better-auth/guarding-the-auth-api-itself.md.

Never roll your own crypto

Better Auth already signs the session cookie, hashes passwords (scrypt), single-uses magic-link and reset tokens, and constant-time compares them. In this repo that means:

  • No crypto.createHash("sha256") over a user id to make "a quick token".
  • No Math.random(), Date.now() or nanoid() used as a credential. A token someone can predict is a token someone can mint.
  • No hand-written JWT signing or verification, and no second cookie that carries identity beside the session cookie.
  • No === on secrets. Comparison of a submitted token against a stored one happens inside Better Auth, in constant time, or it does not happen.

If a flow seems to need a new kind of token (an invite link, an email change confirmation, an API key), the answer is a Better Auth plugin or a row with an expiry, a single-use flag and a crypto.randomUUID() value that is hashed before storage. Never a homemade signature scheme.

Runtime and caching
  • The catch-all handler runs on the Node runtime. The adapter opens a TCP connection to Postgres; the edge runtime cannot. export const runtime = "nodejs" in that route is load-bearing.
  • Any route or page that reads the session must be dynamic. A cached response built for one visitor is served to the next one, and the session is the one thing that must never be shared.
  • The corollary: never read the session in src/app/layout.tsx. One getSessionUser() there makes every route in the repo dynamic, marketing pages and blog posts included. The session provider subscribes in the browser for exactly this reason. A segment that is already per-visitor may read it in its own layout.
  • Never read document.cookie looking for the session. It is HttpOnly, so a successful read means the cookie is misconfigured.
Rate limiting

auth.ts enables Better Auth's limiter with storage: "database", in every environment. Three things follow, and each of them has been the cause of a real outage somewhere:

  • Do not switch it back to memory. This app runs on serverless functions. An in-process counter is one counter per warm instance, so the effective limit is the configured one times the fleet size, which for /sign-in/magic-link and /request-password-reset means an unbounded relay for mailing strangers from your verified domain, and for /sign-in/email a password-guessing budget multiplied by every warm instance.
  • Do not delete the rate_limit table or drop it from the schema. With database storage configured and no table, every request to /api/auth/* fails on a missing relation.
  • Do not remove rateLimit.customStorage. It counts each request in one atomic statement (consumeRateLimitRow in src/lib/auth/adapter.ts). Better Auth's own database storage lets a burst of simultaneous sign-ins past the limit on Postgres. The server actions that check a password or send an email (src/lib/auth/actions.ts) count through the same function, per account, because Better Auth's limiter never sees a server action.
  • Do not remove advanced.ipAddress.ipAddressHeaders. Without a resolvable client IP the limiter falls back to a single shared bucket for the whole internet, and the first few sign-ins each minute lock out everybody else. Behind a proxy of your own, add trustedProxies rather than widening the limits.

Enforce limits on the server. Disabling a button after a click is feedback, not a control.

Secrets and origins
  • BETTER_AUTH_SECRET is read only inside auth.ts (and setup.ts, which only asks whether it is set). It is never logged, never passed to a client component, never included in an error message.
  • A missing secret, BETTER_AUTH_URL or DATABASE_URL turns sign-in off (setup.ts): the auth pages say what to set, /api/auth answers 503, getSession() answers null. Keep new auth entry points behind the same check instead of letting them 500.
  • Each environment gets its own secret. A preview deployment sharing production's secret means a cookie minted on a preview URL is valid in production.
  • BETTER_AUTH_URL seeds trustedOrigins, which is the CSRF check. Do not loosen it to "*", and do not add an origin you do not control. VERCEL_URL is the one exception and it is added conditionally: the platform sets it, it names the current preview deployment, and it is absent everywhere else.
  • Rotating the secret invalidates every session. That is a deliberate, announced act. See the session-invalidation solution doc.
Errors that reach the user

Every message comes from src/lib/auth/errors.ts, keyed by Better Auth's error code, so the forms, the pages and the server actions say the same thing.

  • A failed password sign-in is "Email or password is incorrect.", never "no such account". A failed emailed link is "invalid or already used, ask for a new one".
  • "Forgot password" and "email me a link" show the same "check your inbox" panel whether or not the address exists, and Better Auth sends the email after the response (advanced.backgroundTasks), so the timing matches too.
  • Sign-up is the one place that says "already has an account", and only while REQUIRE_EMAIL_VERIFICATION is off: with it on, Better Auth answers a taken address like a new one. The trade-off is written next to the flag in src/lib/auth/policy.ts.

Those properties are easy to break by "improving" the error handling. Keep errors next to the field or in an Alert at the top of the form, and never only in a toast.

Sign-in methods: where each one is switched
MethodSwitchNotes
Email and passwordPASSWORD_SIGN_IN in src/lib/auth/policy.tsOff hides the password field, sign-up form, "Forgot password?" and the password card, and Better Auth refuses the endpoints
Magic linkMAGIC_LINK_SIGN_IN in policy.tsOff removes the option and lists its two endpoints in disabledPaths
Email verification requiredREQUIRE_EMAIL_VERIFICATION in policy.tsOff by default; the trade-off is in the comment above it
Google, GitHub, Microsoftboth of the provider's env keys setsrc/lib/auth/providers.ts is the only file that reads them

Change a method there and nowhere else. The pages, the forms, the settings cards and auth.ts all read these, so a button can never point at a method the server refuses.

Sign-in UI rules
  • A button only for a configured provider. Pages call enabledOAuthProviders() on the server and pass { id, label } to the client. Never hardcode a "Continue with Google" button, and never pass keys, secrets or process.env reads to a client component.
  • Google first, then GitHub, then Microsoft. The order is the order of OAUTH_PROVIDERS. A new provider goes into that array (see the add-oauth-provider skill), not into a page.
  • TRUSTED_LINKING_PROVIDERS stays short. It lets a provider join an existing account with the same email even when the provider does not vouch for the address. Only Google is in it. Adding a provider that lets users or tenant admins set unverified addresses (GitHub, Microsoft Entra) is an account-takeover bug.
  • Every redirect target goes through safeNext() before it reaches callbackURL, redirect() or a link. It judges the normalised path as well as the raw string: /..//evil.example folds to //evil.example. Emailed links and OAuth failures land on the sign-in page via authErrorURL(next), which turns ?error= into a sentence from errors.ts.
  • Changes to an existing account are server actions in src/lib/auth/actions.ts: validated with zod, first line checks the session, and every change is refused while an admin impersonates the account (the endpoint guard refuses the raw endpoints too). Only calls that must redirect the browser (OAuth sign-in and linking) or answer before the page moves (sign-in, sign-up, reset) use authClient.
  • Session tokens never reach the browser. The sessions list maps Better Auth's rows to ids on the server; revoking looks the token up again inside the action.
  • setPassword is server-only in Better Auth. An account that signed up with OAuth or a magic link gets "Set a password" through setPasswordAction; an account with a password gets "Change password". Setting one needs a recent sign-in, checked in the action and in the guard.
  • Build with the kit. Forms use Field, FieldLabel, FieldControl, Input (or PasswordInput), FieldError and an Alert for the form-level error. On a failed submit, focusFirstError() from @/components/auth/focus-first-error moves focus to the first invalid field so a screen reader reads its error. Accessible names are part of the contract: the end-to-end tests find "Email", "Password", "Sign in", "Create account" and "Continue with Google" by role and label.
Adding an auth email

Emails for auth go through sendAuthEmail in auth.ts, which imports @/lib/email and the template lazily (the Better Auth CLI loads auth.ts outside Next, where a static import of a server-only module throws). Add the template next to magic-link.tsx, reset-password.tsx and verify-email.tsx in src/lib/email/templates/, and give the plain-text part the raw URL.

Roles are decided on the server, every time

Loads onsrc/app/**src/components/**src/lib/auth/**.claude/rules/roles-are-server-side.md
The rule

Every server action, route handler and protected page re-reads the role from the session on the server and re-checks it. Not once at the edge, not once at login, not once in a layout that a later refactor might move: on every request that does something privileged.

// server action
"use server";
import { requireRole } from "@/lib/auth/session";

export async function deleteAccount(userId: string) {
  await requireRole("admin");   // first line, before any argument is trusted
  // ...
}
Never trust a role that arrived from the client

These are all the same bug:

// no
export async function promote(formData: FormData) {
  if (formData.get("role") === "admin") { /* ... */ }
}

// no
export async function POST(request: Request) {
  const { userId, isAdmin } = await request.json();
  if (isAdmin) { /* ... */ }
}

// no
const role = request.headers.get("x-user-role");

A form field, a JSON body, a header, a query parameter and a localStorage value are all attacker-controlled. The only trustworthy answer to "who is this and what are they" comes from getSessionUser(), which reads the signed cookie server-side.

The same applies to identity, not just role: never accept a userId from the client and act on it. Take the id from the session and use the client's value only to name the target of an action, after checking the actor may act on it.

Client-side role checks are cosmetics

useSessionUser() and useCanSee() from @/components/auth/session-provider exist so an admin link is not rendered for a regular user. That is a courtesy. The endpoint behind the link is what an attacker calls, so it does its own requireRole.

They also start out null on every page load (the session is fetched in the browser after hydration) and stay null for a suspended account. Neither is a security property; both are reasons to render a neutral state rather than branching on !user as if it meant "signed out".

A checklist for any new privileged feature:

  1. The page or action calls requireUser / requireRole before anything else.
  2. Hiding the entry point in the UI is a second, independent step.
  3. If (1) is missing, the feature is broken even though it looks correct in the browser.
Where a role may be written
  • Roles change through the Better Auth admin plugin (auth.api.setRole, which the admin panel calls), bun run auth:make-admin <email> for the first admin, or a deliberate SQL statement run by a human. Never through a route that takes the new role from the request body without a requireRole("admin") above it.
  • A user may never set their own role, including at sign-up. The role column has a NOT NULL DEFAULT 'user'; nothing in the sign-up path overrides it.
  • The role vocabulary lives in src/lib/auth/roles.ts. Add a role there and nowhere else. A string literal "editor" compared inline is a role that exists in one file and is silently absent from every other check. Add it to RANK in the same edit; hasRole fails closed on a role it cannot rank, so a half-added role silently denies everything instead of erroring. src/lib/auth/guards.test.ts reads ROLES rather than a hardcoded list, so it keeps covering the new one.

Sessions carry a short signed cache of the user row, so most requests answer without a database read. A role written directly into the table is therefore not visible for up to five minutes. Promotions and demotions that must take effect immediately go through the admin plugin API, which refreshes the cache, or are followed by revoking that user's sessions.

Skills (2)

Invoked by name.

  • /add-oauth-provider

    Turn on Google, GitHub or Microsoft sign-in (env keys only), or add another OAuth provider to the list in src/lib/auth/providers.ts.

    .claude/skills/add-oauth-provider/SKILL.md

  • /protect-route

    Put an authentication or role check on a page, a route handler, a server action or a whole route group, at the right layer, without a redirect loop.

    .claude/skills/protect-route/SKILL.md

Solution docs (10)

Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.

Show all 10

How it fits

What Better Auth needs, and what it goes well with

The resolver enforces this before it generates anything, and names every addition it makes.

Requires

  • An ORM battery. The resolver adds the default one for you and tells you why.
  • A database battery. The resolver adds the default one for you and tells you why.
  • An email battery. The resolver adds the default one for you and tells you why.

Pairs well with

Nothing extra. Add any tested battery alongside Better Auth.

Build a repo with Better Auth

Free and MIT. The builder opens with Better Auth picked. You download the zip right away, and we email you the link too.

Presets

Presets that already include Better Auth

A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.