Authentication
How Better Auth is configured in the Next.js + Cloudflare D1 starter, how admins are assigned, where the admin app lives, and what protects application data.
This kit uses Better Auth with the Drizzle adapter on top of the same D1 database that holds your workspaces and projects. There is no separate auth service to run. The shared authentication page explains the concepts that apply to all kits; this page is about where things live in this repository.
Where the configuration lives
| What | Path |
|---|---|
| Better Auth server instance | apps/web/src/auth/server.ts (createAuth(env)), reused by the admin app through its @/* path alias |
| Browser client | apps/web/src/auth/client.ts |
| Social provider wiring | apps/web/src/auth/social-providers.ts |
| Auth tables (generated Drizzle schema) | apps/database/src/schema/auth.generated.ts, regenerate with pnpm auth:generate |
| Route handlers | apps/web/src/app/api/auth/[...all]/route.ts and apps/admin/src/app/api/auth/[...all]/route.ts |
| Web login, sign-up and recovery pages | apps/web/src/app/(dynamic-pages)/(login-pages)/ and (misc-login-pages)/ |
| Admin login and recovery pages | apps/admin/src/app/login/, forgot-password/, update-password/, two-factor/ |
| Web middleware chain | apps/web/src/middleware.ts and apps/web/src/middlewares/ |
| Admin middleware | apps/admin/src/middleware.ts |
createAuth is built per request from the Worker env, so it reads BETTER_AUTH_SECRET, BETTER_AUTH_URL, NEXT_PUBLIC_SITE_URL, ADMIN_EMAIL and the provider credentials from the bindings described in environment variables. Each Worker's own NEXT_PUBLIC_SITE_URL is its only trusted origin, and cookies are marked secure when that URL starts with https://. When ADMIN_APP is true, the cookie prefix changes from better-auth to nextbase-admin, so the web and admin sessions never share a cookie even on the same parent domain.
Email, password and verification
Email and password sign-up is enabled and requireEmailVerification is on. A verification email is sent on sign-up and on sign-in attempts by unverified users, and the user is signed in automatically after clicking the link. Password resets revoke all sessions and clear trusted devices.
Auth emails are written to the D1 transactional outbox first. When USE_LOCAL_EMAIL is true and MAILPIT_API_URL is set, the Worker also posts the message to Mailpit and marks the outbox row complete. Otherwise the platform Worker's scheduled dispatcher delivers it through Resend. See installation for starting Mailpit, and emails for production delivery.
The local seed creates already-verified accounts so you can skip the inbox entirely during development.
Magic links, two-factor and API keys
- Magic links are enabled through the
magicLinkplugin with a 15-minute expiry. Sign-up through a magic link is allowed on the web app; the admin login page also offers a magic-link form. - Two-factor uses the
twoFactorplugin with TOTP and email OTP. Users manage it under/user/settings/security; the challenge page is/two-factoron both apps. - API keys use
@better-auth/api-keywith thest_prefix, a required name, metadata, and a rate limit of 120 requests per minute. Keys are accepted asBearertokens or in thex-api-keyheader and are hashed in the database. Users create them under/user/settings/developeron the web app.
Social providers
Google, GitHub and X are supported. A provider appears on a login page only when both of its credentials are present in that Worker:
| Provider | Variables |
|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | |
| GitHub | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
| X | TWITTER_API_KEY, TWITTER_API_SECRET |
Register the callback URL as <origin>/api/auth/callback/<provider> in each provider's console, where <provider> is google, github or twitter. Register the web origin, and the admin origin too if administrators should sign in with a provider there. Put the credentials in apps/web/.dev.vars and apps/admin/.dev.vars locally and in each Worker's secrets in production.
Sessions
Sessions live in the D1 session table and expire after 30 days, refreshed once a day. A short cookie cache (60 seconds, JWE) keeps page loads fast without a database read on every request. Users can revoke sessions from their security settings.
Better Auth's built-in rate limiting is on (100 requests per 60 seconds), and the kit adds its own AUTH_RATE_LIMITER Workers rate-limit binding (10 requests per 60 seconds per key) in a before hook. If the limiter binding fails, auth returns 503 rather than running unprotected.
Becoming an application admin
Admin status is the role column on the Better Auth user table, managed by the admin plugin with adminRoles: ["admin"]. Two trusted paths assign it:
ADMIN_EMAIL: adatabaseHooks.user.create.beforehook setsroletoadminwhen the new account's email matchesADMIN_EMAIL(case-insensitive). Set the same address in thevarsblock ofapps/web/wrangler.jsoncandapps/admin/wrangler.jsoncbefore the first sign-up with that address.- Local seed:
pnpm db:seedinsertsadmin@example.comwith the admin role.
The repository doesn't ship a UI or script to promote an existing account afterwards. Change the role column through a trusted server-side path, for example wrangler d1 execute, rather than from the app.
The admin app
Platform administration lives in apps/admin, a separate Next.js Worker on its own origin: http://localhost:3001 locally and https://nextjs-d1-admin.usenextbase.com for the demo. The customer web app no longer has /app-admin routes or admin navigation. On the admin origin:
/loginsigns the administrator in with the shared Better Auth tables but an independentBETTER_AUTH_SECRETand thenextbase-admincookie prefix.apps/admin/src/middleware.tsredirects anonymous visitors to/login?next=/app-admin, returns403for accounts without theadminrole and for impersonation sessions, and marks every pageno-storeandnoindex.- The
/app-admin/(admin-pages)layout re-checks the role withserverGetUserType()and rendersunauthorized()otherwise. - Users are at
/app-admin/users, workspaces at/app-admin/workspaces, billing at/app-admin/payment-gateway, marketing content under/app-admin/marketing/..., settings at/app-admin/settings, and feedback and roadmap moderation at/feedbackand/roadmap.
Impersonation crosses the two origins. From /app-admin/users the admin app writes an audit row, asks Better Auth for a one-minute single-use token and redirects to <NEXT_PUBLIC_WEB_APP_URL>/auth/impersonate?token=.... The web route verifies the token, re-checks that the impersonating account is still a verified, unbanned admin and that the target is not an admin, and only then sets the impersonation session (up to one hour). The web impersonation banner links back to <NEXT_PUBLIC_ADMIN_APP_URL>/app-admin/users. Impersonation sessions are rejected by the MCP endpoints and by the admin middleware. The /admin/remove-user Better Auth path is disabled so account deletion goes through the application's token flow, which also cleans up R2 media.
What is protected and how
- Middleware is optimistic.
apps/web/src/middleware.tsruns guest, auth and onboarding checks for the matched route; the admin middleware gates the admin origin. In both apps, layouts, server actions and repositories perform the authoritative checks again. - Every tenant operation takes an
Actor. TheActortype inapps/database/src/authorization.tsisanonymous,user(with optionalisAppAdmin) orservice(platform-workerormigration). Repositories check a permission such asprojects.readormembers.manageagainst the member's workspace role (owner,admin,member,readonly) plus any per-member overrides stored inworkspace_members.permissions. - No raw bindings in UI code. Request code obtains
APP_DBthroughgetCloudflareContext; browser clients never receive a database binding. Theapps/web/src/d1-clients/andapps/web/src/data/folders are the server-only data layer; the admin app addsapps/admin/src/data/admin/. - No interactive transactions. D1 doesn't support them, so multi-statement workflows use
D1Database.batch()or conditional compare-and-set updates. Side effects go through the transactional outbox and are delivered idempotently by the platform Worker. - Companion rows are reconciled. Better Auth's user insert and the application's profile rows can't share one transaction on D1, so an idempotent reconciliation runs after user creation, user updates and session reads until it succeeds.
For the agent side of authentication (consent, token lifetimes, what ends access), see agent connections.