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 moreShow fewer
- 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.
- 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, oropenssl 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.
- Where to get it
- http://localhost:3000 in development. In production set it to your real origin, for example https://my-app.com, with no trailing slash.
- 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 handlersrc/lib/auth/session.ts: the server-side session helperssrc/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 sessionsscripts/auth/*: terminal scripts, run throughtsServer
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-socialand/change-emailneed a session created withinRECENT_SIGN_IN_MINUTES(src/lib/auth/policy.ts):403 RECENT_SIGN_IN_REQUIRED. The UI answers it withReauthenticateAlert("Confirm it's you": sign out, sign in with?next=/settings/security, come back).
Rules for changing it:
- Keep
endpointGuardafter every other plugin and beforenextCookies(). 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 byoperationIdinRECENT_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 inIMPERSONATION_ALLOWED_PATHSonly 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()ornanoid()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. OnegetSessionUser()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.cookielooking for the session. It isHttpOnly, 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-linkand/request-password-resetmeans an unbounded relay for mailing strangers from your verified domain, and for/sign-in/emaila password-guessing budget multiplied by every warm instance. - Do not delete the
rate_limittable 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 (consumeRateLimitRowinsrc/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, addtrustedProxiesrather 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_SECRETis read only insideauth.ts(andsetup.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_URLorDATABASE_URLturns sign-in off (setup.ts): the auth pages say what to set,/api/authanswers 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_URLseedstrustedOrigins, which is the CSRF check. Do not loosen it to"*", and do not add an origin you do not control.VERCEL_URLis 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_VERIFICATIONis off: with it on, Better Auth answers a taken address like a new one. The trade-off is written next to the flag insrc/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
| Method | Switch | Notes |
|---|---|---|
| Email and password | PASSWORD_SIGN_IN in src/lib/auth/policy.ts | Off hides the password field, sign-up form, "Forgot password?" and the password card, and Better Auth refuses the endpoints |
| Magic link | MAGIC_LINK_SIGN_IN in policy.ts | Off removes the option and lists its two endpoints in disabledPaths |
| Email verification required | REQUIRE_EMAIL_VERIFICATION in policy.ts | Off by default; the trade-off is in the comment above it |
| Google, GitHub, Microsoft | both of the provider's env keys set | src/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 orprocess.envreads 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 theadd-oauth-providerskill), not into a page. TRUSTED_LINKING_PROVIDERSstays 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 reachescallbackURL,redirect()or a link. It judges the normalised path as well as the raw string:/..//evil.examplefolds to//evil.example. Emailed links and OAuth failures land on the sign-in page viaauthErrorURL(next), which turns?error=into a sentence fromerrors.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) useauthClient. - 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.
setPasswordis server-only in Better Auth. An account that signed up with OAuth or a magic link gets "Set a password" throughsetPasswordAction; 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(orPasswordInput),FieldErrorand anAlertfor the form-level error. On a failed submit,focusFirstError()from@/components/auth/focus-first-errormoves 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:
- The page or action calls
requireUser/requireRolebefore anything else. - Hiding the entry point in the UI is a second, independent step.
- 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 arequireRole("admin")above it. - A user may never set their own role, including at sign-up. The
rolecolumn has aNOT 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 toRANKin the same edit;hasRolefails closed on a role it cannot rank, so a half-added role silently denies everything instead of erroring.src/lib/auth/guards.test.tsreadsROLESrather than a hardcoded list, so it keeps covering the new one.
The cookie cache
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.
- Account linking and email verification without account takeoversWhen "Continue with Google" joins an existing password account, when it refuses, and why an unverified email address or a trusted provider list can hand one person's account to another.docs/solutions/better-auth/account-linking-and-email-verification.md
- Better Auth on the edge: why your session check fails in middlewareEdge runtimes have no TCP sockets and no Node crypto, so a session lookup that works in a page throws in the proxy. Read the cookie there and verify in the render.docs/solutions/better-auth/better-auth-on-the-edge.md
- CSRF, SameSite and the cookie flags that make a session safeWhat each session cookie flag actually defends against, why trustedOrigins is your CSRF check, and the three configuration changes that quietly disable both.docs/solutions/better-auth/csrf-and-cookie-flags.md
- Guard Better Auth's endpoints, not just your settings formsEvery /api/auth endpoint is a public URL. One hook refuses account changes from an impersonation session and asks for a recent sign-in before a password or provider is added.docs/solutions/better-auth/guarding-the-auth-api-itself.md
- The magic-link token: single use, ten minutes, and the scanner that clicks it firstHow long the credential lives, why a corporate mail scanner burns it before the human arrives, and why the rate limiter has to be backed by your database rather than by process memory.docs/solutions/better-auth/magic-link-tokens-and-scanners.md
Show all 10Show fewer
- Moving an existing user table onto Better Auth without logging everyone outMap your columns to the four required tables, backfill ids and accounts, and let people migrate themselves on next sign-in instead of forcing a password reset.docs/solutions/better-auth/migrating-an-existing-user-table.md
- oauth-callback-url-mismatchesdocs/solutions/better-auth/oauth-callback-url-mismatches.md
- Password reset tokens that cannot be replayed, guessed or leakedOne hour, single use, answered the same way for every address, and kept out of logs, Referer headers and search results. What Better Auth does for you and the four things it cannot.docs/solutions/better-auth/password-reset-tokens.md
- Modelling roles you will not regret when the admin panel growsA role column, a ranked vocabulary in one file, and permission checks that name the action, not a boolean isAdmin scattered across forty components.docs/solutions/better-auth/role-modelling-for-the-admin-panel.md
- Session invalidation, or why everyone got logged out on deployA rotated secret, a changed cookie name or a wiped database invalidates every session at once. Here is what invalidates what, and how to revoke one user on purpose.docs/solutions/better-auth/session-invalidation-and-logged-out-on-deploy.md
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.
Cannot be combined with
Compared with the alternatives
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.