Authentication
How Better Auth is configured in the Next.js + Neon starter, how sessions, the separate admin app and impersonation work, and what protects your data.
This kit uses Better Auth. Users, sessions and accounts live in your own Postgres database, and the same login is used by the website, the admin app and agent connections. For the concepts shared by all kits, read the v5 authentication guide.
Where the configuration lives
| What | Path |
|---|---|
| Server configuration (plugins, email hooks, session settings) | apps/web/src/lib/auth/server.ts |
| Browser client | apps/web/src/lib/auth/client.ts |
| Better Auth API route, web app | apps/web/src/app/api/auth/[...all]/route.ts |
| Better Auth API route, admin app | apps/admin/src/app/api/auth/[...all]/route.ts |
Session helpers (getCurrentSession, requireSession, requireAdminSession) | apps/web/src/lib/auth/session.ts |
Actor contracts (requireActor, requireWorkspaceActor) | apps/web/src/lib/auth/actors.ts |
| Workspace roles and permissions | apps/web/src/lib/auth/organization-access.ts |
| Secondary storage, rate limits and locks | apps/web/src/lib/auth/secondary-storage.ts |
| Impersonation handoff bookkeeping | apps/web/src/lib/auth/support-handoff.ts and apps/web/src/lib/cross-app-url.ts |
| Auth tables | apps/database/src/schema.ts |
The admin app has no auth code of its own. Its @/* alias falls back to apps/web/src, so both apps run the same createAuth() from server.ts against the same database, each with its own origin.
Better Auth's models keep their names in code but map to the app tables: user is users, session is sessions, organization is workspaces, member is workspace_members and subscription is subscriptions.
The environment variables that matter are BETTER_AUTH_URL, BETTER_AUTH_SECRET, NEXT_PUBLIC_APP_URL and, optionally, BETTER_AUTH_TRUSTED_ORIGINS. Trusted origins are BETTER_AUTH_URL, NEXT_PUBLIC_APP_URL and anything in the comma-separated extra list. Each app sets BETTER_AUTH_URL and NEXT_PUBLIC_APP_URL to its own origin, and both must share BETTER_AUTH_SECRET. Cookies are host-only, so a session on the web host is not a session on the admin host.
Sign-up, verification and the local inbox
Email and password is enabled with requireEmailVerification: true. A new account can't sign in until the verification link is opened. The pages on the web app are /sign-up (also /signup), /login, /forgot-password and /update-password.
Verification, password reset, email change and account deletion emails are rendered from apps/email and sent through apps/web/src/lib/email/send-email.ts. Locally, EMAIL_PROVIDER=smtp points at Mailpit, so open http://127.0.0.1:8025 to find the message. In production, set EMAIL_PROVIDER=resend and RESEND_API_KEY. See emails with Resend.
Account deletion sends a confirmation link that expires after one hour. Email change sends a confirmation to the current address.
Magic links and social providers
The magicLink plugin is enabled. The login and sign-up pages include magic link forms, and the email is sent through the same email setup. Administrators can also send a customer a login link from the admin app; that action posts to the web app's /api/auth/sign-in/magic-link so the link lands on the web origin.
Google, GitHub and X (Twitter) are configured in server.ts, but only when both the client ID and client secret for a provider are set. Out of the box, the login form in apps/web/src/components/auth-form.tsx offers email and password plus the magic link form; it renders no social buttons, and the README says the same. If you want social login, set the provider variables and add the buttons that call the Better Auth client's social sign-in. The provider icons already exist in apps/web/src/components/authentication/icons-list.tsx. Register the callback URL with your provider for the origin in BETTER_AUTH_URL.
Sessions
Sessions are stored in the database (storeSessionInDatabase: true) and expire after 7 days, extended once a day while in use. The cookie cache is disabled, so every request validates against the sessions table.
Rate limiting is on (100 requests per 60-second window) and uses Better Auth's secondary storage. With VALKEY_URL set that is Valkey; without it the kit uses the Postgres tables auth_secondary_storage and auth_rate_windows and transaction-scoped advisory locks, which work through Neon's connection pooling.
Users can also create API keys under /user/settings/developer. Keys use the nb_ prefix, require a name, and have their own rate limit.
Workspaces and roles
Workspaces are Better Auth organizations. Any user can create one, up to BETTER_AUTH_ORGANIZATION_LIMIT (default 10). Roles are owner, admin, member and readonly, and permissionsForRole in actors.ts maps them to permissions such as workspace:write, members:write, billing:write and projects:write.
Invitations expire after 7 days. The email links to /api/invitations/view/<id>, and the user accepts under /user/invitations or /accept-invitation. Creating a workspace also creates its workspace_settings and workspace_application_settings rows and writes an audit and outbox event.
The admin app and application administrators
Application admin is separate from owning a workspace. It is the Better Auth admin plugin's role column on the users table.
The admin area lives in apps/admin, served on its own origin (port 3001 locally, NEXT_PUBLIC_ADMIN_APP_URL in production). Administrators sign in at /login on the admin host with email and password; that page has no magic link, social or sign-up option, and it redirects straight to /app-admin when an admin session already exists. Every route under apps/admin/src/app/(dynamic-pages)/(authenticated-pages)/ runs requireAdminSession, which sends anyone without role = 'admin', or anyone inside an impersonation session, to /login?error=forbidden. The web app's sidebar shows an Open admin panel link to <NEXT_PUBLIC_ADMIN_APP_URL>/app-admin for administrators; the web app itself no longer serves /app-admin.
How an account gets the role:
- Locally, the seed creates
admin@example.comwith the admin role (passwordpassword123). - An existing administrator can change another user's role from the users page in the admin app. The action in
apps/admin/src/data/admin/user.tsxcalls Better Auth'ssetRoleand writes anadmin.user_role_updatedaudit entry. - For a fresh production database, the repository has no script for the first administrator. The code reads
users.role, so set that column toadminfor your own account using trusted database access (for example the Neon SQL editor), then use the admin app for everyone else. Don't expose this through the app.
Admins can also ban and unban users, create users and send login links.
Impersonation across the two apps
Impersonation starts in the admin app and lands in the web app:
- The admin action calls Better Auth's
impersonateUser, which creates a support session carryingimpersonatedBy, and theoneTimeTokenplugin issues a one-use token that lives for one minute. The action records anadmin.user_impersonatedaudit entry and registers the handoff with the issuing admin session; if either write fails, the new support session is revoked. - The browser is sent to
<NEXT_PUBLIC_WEB_APP_URL>/auth/impersonationwith the token in the URL fragment, so it never reaches a server log. - That page posts the token to
/api/impersonationon the web app, which only accepts same-origin requests, verifies the token, checks that the session is an impersonation session for a user who isn't banned, and consumes the handoff. Consuming re-checks the issuing administrator's role, ban state and exact session in the database; if any check fails, the support session is deleted and no cookie is set. - On success the web app sets its own host-only session cookie and opens
/dashboard. An impersonation banner offers to stop; stopping signs the support session out and returns to<NEXT_PUBLIC_ADMIN_APP_URL>/app-admin/users, where the admin's own session is still valid.
The MCP servers refuse impersonation sessions, and requireAdminSession refuses them too.
What is protected and how
There is no row-level security for application data in this kit (the only exception is the mcp_records table, which is restricted to the server's database role). Protection is explicit in server code:
apps/web/src/proxy.tsredirects requests to/dashboard,/onboarding,/project,/userand/workspaceto/login?next=...when there is no session cookie. This is only a convenience; it doesn't authorize anything.- Layouts under
app/(dynamic-pages)/(authenticated-pages)in the web app callunauthorized()when there are no claims. In the admin app, the equivalent layout callsrequireAdminSession. - Server actions use
authActionClientoradminActionClientfromapps/web/src/lib/safe-action.ts, validate input with zod, and then check workspace membership withrequireWorkspaceActororgetWorkspaceMembership. Administrative queries and actions live only inapps/admin/src/data/admin/. - Policies in
apps/web/src/lib/domain/*-policy.ts(assertProjectWrite,requireWorkspaceManagerand friends) hold the rules, with unit tests next to them. - Mutations insert audit log and outbox rows in the same
db.transactionthroughdomainEventValues, so every change has a trail and a realtime event. Admin mutations also callrefreshWebAfterAdminMutation, which posts to the web app's/api/internal/admin-revalidatewithREVALIDATE_SECRETso public pages pick up CMS changes. - Banned users are redirected to
/login?error=banned, and the MCP servers re-check verification, ban state and role on every call.
When you add a feature, keep the pattern: parse input, resolve the actor, check the workspace role, then query or write with the workspace ID from the server, never from the client. The AGENTS.md files in the repository spell this out for coding agents too.
Environment variables
Every variable in the Next.js + Neon starter's .env.example and apps/admin/.env.local.example, what it does and where the value comes from.
Agent connections
The User MCP and Admin MCP servers in the Next.js + Neon starter: endpoints on the web and admin origins, tools, OAuth behaviour and how to add a tool.