Nextbase Docs
Nextbase Docs
HomeBlogWelcome to Nextbase v5!Choose your starterGetting startedLocal Supabase per agent
TanStack + NeonInstallationEnvironment variablesAuthenticationAgent connectionsProject structureDeployment
AuthenticationWorkspaces and the admin area
Building with agentsEmails with Resend
TroubleshootingNextbase v5
The 8 starter kitsTanStack + Neon

Authentication

How Better Auth, global and workspace roles, the separate admin app and PostgreSQL row-level security work in the TanStack + Neon starter.

This kit uses Better Auth with database sessions. The configuration is one file, apps/web/src/server/auth.ts, and it talks to PostgreSQL through the Drizzle adapter on a dedicated auth_runtime connection. The admin app imports the same module, so both deployments share one user table and one set of rules. The shared authentication page explains the concepts that are the same across kits; this page covers what this kit actually does.

Sign-in methods

MethodStatusNotes
Email and passwordOnMinimum 8 characters. Email verification is required before a session is issued, and the kit signs you in automatically after verification
Password resetOn/forgot-password requests a link, /reset-password sets the new password
Magic linkServer onlyThe magicLink plugin is registered with 5-minute hashed tokens and the client plugin is loaded, but the shipped login page has no magic link form. Add one with authClient.signIn.magicLink
Google, GitHub, XOptionalEach provider is registered only when both of its env variables are set. There are no provider buttons in the shipped UI yet. See environment variables

Rate limiting is enabled and stored in the database (public.rate_limit), so it works across several server instances. Cookies use the nextbase prefix and are marked secure when NODE_ENV=production. The client IP comes from the x-nextbase-client-ip header that Express sets from the trusted proxy.

The web pages are /sign-up, /login, /forgot-password and /reset-password. Better Auth's own HTTP endpoints are mounted at /api/auth/* on both apps. After sign-in the kit honours a returnTo query parameter, but only for same-origin paths, and it does a full document load when the destination is an /mcp/ page because those are served by Express rather than React Router.

Email verification and the local inbox

Verification, password reset and magic link emails are written to app.outbox_jobs with an idempotency key (enqueueEmail in apps/web/src/server/email.ts). A separate process sends them over SMTP. Locally that means:

  1. Run pnpm --filter @nextbase/web worker in a second terminal, or nothing is sent.
  2. Open Mailpit at http://localhost:38025 and follow the link.

In production the same code runs from Vercel's waitUntil and the 15-minute cron described in deployment. A magic link that has already expired is never sent on a late retry. See emails with Resend for the provider side.

Account pages

Signed-in users have /app (overview with their workspaces), /app/workspaces (create a workspace), /app/projects and /app/settings. The settings page is a static form in this kit; wire it to Better Auth's updateUser and changeEmail when you build account management.

Global roles and the admin app

The Better Auth admin plugin defines three global roles in auth.ts:

RoleWhat it can do
userDefault for every new account
supportList and read users, ban, list and revoke sessions
adminEverything support can do, plus set roles and impersonate. Only admin opens the admin app

The admin area is a separate application, apps/admin, on its own origin (http://localhost:3001 locally, https://tanstack-neon-admin.usenextbase.com for the demo). Its routes are /login, /app-admin, /app-admin/users and placeholder sections for workspaces, payment, marketing, authors, tags, blog, changelog, feedback, roadmap and settings. The layout loader and every page loader call requireAppAdmin from apps/web/src/server/authorization.ts, which redirects anonymous visitors to /login?returnTo=/app-admin and returns 403 to anyone whose global role is not admin or whose session is an impersonation.

The web app no longer serves /app-admin. It also returns 404 for /api/auth/admin/* and /api/auth/one-time-token/*, so the Better Auth admin endpoints are reachable only on the admin origin, and there only through the kit's own flows.

Becoming an admin

There is no sign-up path to admin, and the seeded admin@nextbase.local user has no credential at all, so you cannot sign in as it. The trusted flow is a direct database update run by someone with the migration_owner credentials:

pnpm admin:grant -- you@example.com

This runs apps/database/src/grant-admin.ts, which sets role = 'admin' on the matching Better Auth user and fails if the email doesn't exist. Sign out and in again so the new role is loaded, then sign in on the admin origin with the same email and password. Use the same command against production with DATABASE_DIRECT_URL pointing at Neon's direct endpoint.

Impersonation

Impersonation starts only from /app-admin/users in the admin app. The action requires a same-origin form post, a non-impersonated admin session and a support reason of 5 to 500 characters. It then:

  1. Calls Better Auth's impersonateUser, which creates a 15-minute session for the target (impersonationSessionDuration: 900).
  2. Writes an admin.impersonation.started audit event with the actor and the subject inside withActorTransaction, and registers a one-minute, single-use handoff record keyed to the issuing admin session. If either write fails, the new session is deleted.
  3. Redirects to {WEB_APP_URL}/auth/impersonation#token=... with a hashed one-time token in the URL fragment and Referrer-Policy: no-referrer.

On the web origin, /auth/impersonation posts the token to /api/impersonation. The server verifies the token, rechecks in the database that the issuing session still belongs to a non-banned admin, consumes the handoff record and only then sets host-only cookies. The app layout shows a "You are impersonating a user" banner; Return to admin posts to /api/impersonation/stop, which signs the support session out and redirects to {ADMIN_APP_URL}/app-admin/users. The admin's own session is never touched. MCP connections reject impersonated sessions entirely.

apps/admin/app/routes/admin-users.integration.test.ts exercises the real flow against local PostgreSQL: restricted roles, mismatched audit subjects, handoff redemption and single use, host-only cookies and preservation of the administrator session.

Workspace roles

Workspace access is separate from the global role. Each membership in app.workspace_members has one of owner, admin, member or readonly (the app.workspace_role enum). Creating a workspace makes you its owner, and a database trigger (app.prevent_last_owner_removal) stops the last owner from being removed.

Being an application admin doesn't make you a member of every workspace. The User MCP consent page, for example, only offers workspaces your account belongs to.

What is protected and how

Authorization happens twice.

In the domain services. Route loaders, actions and /api/v1 controllers call the same services in apps/web/src/server/services/. A loader starts with requireSession(context) or requireAppAdmin(context); an API handler starts with authenticated(). Services take (principal, requestId, input) and the principal carries actorUserId, subjectUserId, globalRole and impersonating.

In PostgreSQL. Protected work runs inside withActorTransaction (apps/web/src/server/actor-transaction.ts). It opens a transaction on the app_runtime pool and sets app.subject_id, app.actor_id and app.request_id as transaction-local settings. RLS policies read those settings through app.current_subject_id(), app.is_workspace_member(), app.has_workspace_role() and app.is_global_admin() on every statement. Because the identity lives only inside the transaction, this works behind PgBouncer in transaction pooling mode.

Every table in the app schema except outbox_jobs has ENABLE and FORCE ROW LEVEL SECURITY: user_settings, workspaces, workspace_members, workspace_invitations, projects, project_comments, notifications, audit_events, media_assets, feedback, feedback_reactions, user_api_keys, billing_customers, subscriptions, invoices, authors, tags, blog_posts, blog_post_tags and roadmap_items. pnpm db:check verifies forced RLS on the 14 tenant tables it tracks, that no runtime role has BYPASSRLS or superuser or is a member of neon_superuser or function_owner, and that no app schema function is executable by PUBLIC.

The database roles

RoleUsed byTrust level
migration_ownerMigrations, seed, db:check, admin:grant, drizzle-kitOwns the schema. Never used by a running process
auth_runtimeBetter Auth adapter, support handoff records, MCP credential storageTrusted control plane for authentication records only
app_runtimeTenant-facing application queries and outbox insertsConstrained by forced RLS through actor transactions
worker_runtimeOutbox workerClaims and completes jobs only
function_ownerNobody logs inNOLOGIN with BYPASSRLS, owns the reviewed SECURITY DEFINER helpers in 0001_security.sql

Keep auth_runtime credentials out of browser code, general jobs and domain queries. Never weaken RLS or hand a runtime role BYPASSRLS to make a test pass. The integration suite (pnpm test:integration) covers both the allowed and denied side of these policies in apps/database/src/rls.integration.test.ts and apps/web/src/server/services/workspaces.integration.test.ts.

Environment variables

Every variable in the TanStack + Neon starter's env files, what it does and where the value comes from.

Agent connections

User MCP and Admin MCP in the TanStack + Neon starter: settings pages, endpoints on the web and admin origins, tools, OAuth behaviour and where the code lives.

On this page

Sign-in methodsEmail verification and the local inboxAccount pagesGlobal roles and the admin appBecoming an adminImpersonationWorkspace rolesWhat is protected and howThe database roles