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

Authentication

How Better Auth, platform roles, the separate admin app and workspace capabilities protect the TanStack + Cloudflare D1 starter.

This kit uses Better Auth with D1 as its store. There is no Supabase Auth and no JWT claims. Every loader and action re-reads the session, resolves roles on the server, and checks an explicit capability before touching tenant data.

The shared authentication page covers the concepts common to all kits. This page is about what this repository actually does.

Where the configuration lives

ConcernFile
Better Auth instance, plugins, hooksapps/web/app/server/auth.server.ts (shared by the admin app)
Better Auth HTTP handler and redirect handlingapps/web/app/server/auth-handler.server.ts, auth-route.server.ts, route apps/web/app/routes/api-auth.ts (/api/auth/*); the admin app mounts the same handler at apps/admin/app/routes/api-auth.ts
Session to principalapps/web/app/server/session.server.ts
Workspace capabilitiesapps/web/app/server/authorization.server.ts
Impersonation limitsapps/web/app/server/impersonation.server.ts, apps/web/app/routes/impersonate.ts, apps/web/app/routes/stop-impersonating.ts
Auth rate limiting Durable Objectapps/web/workers/auth-rate-limiter.ts, apps/web/app/server/auth-rate-limit.server.ts
Auth tables (user, session, account, verification)apps/database/src/schema/auth.ts
Browser clientapps/web/app/lib/auth-client.ts
Admin app login and consoleapps/admin/app/routes/login.tsx, apps/admin/app/routes/admin.tsx

Better Auth exclusively owns its four tables. Application data about a user goes into projection tables such as user_profiles and user_settings, which a database hook creates right after sign-up and a daily auth.reconcile job repairs if they are missing.

Sign-in methods

Email and password is on with requireEmailVerification: true. Sign-up sends a verification email, and verifying signs the user in automatically. Password reset goes through /forgot-password and /reset-password.

Magic links are on through the Better Auth magicLink plugin. Links expire after 15 minutes and are stored hashed. The login page offers "a secure magic link" as an alternative to the password.

Social providers are optional. auth.server.ts registers Google, GitHub and Twitter only when both the client ID and client secret for that provider are present in the Worker environment. The login page reads the same variables, so a button appears only for a configured provider. Set the provider's callback to SITE_URL + /api/auth/callback/<provider> in that provider's console, and keep SITE_URL equal to the origin users actually visit. The admin app has its own SITE_URL, so register a second callback for it if administrators should use social login there.

Email verification and the local inbox

All auth emails (verification, password reset, magic link, new sign-in notice) are not sent directly. enqueueEmail in apps/web/app/server/email.server.ts writes an email.send row to the transactional_outbox table and nudges the Queue. The jobs Worker renders the React Email template and either sends through Resend or, when RESEND_API_KEY is empty, inserts the message into the dev_email_inbox D1 table.

To finish a local sign-up, read the newest row from that table and open the link. The command is on the installation page. The jobs Worker must be running, which pnpm dev takes care of.

Sessions

  • Sessions live in the D1 session table. The cookie cache is disabled, so every request validates against the database.
  • A session lasts 30 days and is refreshed once a day of activity.
  • Cookies are marked secure when SITE_URL starts with https://.
  • The client IP is read from cf-connecting-ip. Better Auth's own rate limit is 100 requests per 60 seconds, and auth entry points also pass through the AUTH_RATE_LIMITER Durable Object before any credential or email work.
  • A new session triggers a "New sign-in to your account" email.
  • Trusted origins are limited to SITE_URL, and workers/app.ts rejects cross-origin mutations before they reach a route.
  • The admin app runs a second Better Auth instance with the same user table but its own BETTER_AUTH_SECRET, its own SITE_URL and the cookie prefix nextbase-admin, so a web session never works on the admin origin and the other way round.

Platform roles and the admin app

The user.role column holds one of user, support or admin. Only admin grants application administration. requirePlatformAdmin in session.server.ts returns 403 for anyone else.

Application administration lives in the separate admin app, apps/admin, served on its own origin (http://localhost:5174 locally, https://tanstack-d1-admin.usenextbase.com for the demo). Its routes are /login, /forgot-password, /reset-password, /verify-email, /logout, /app-admin/*, /api/auth/*, /api/media/*, /api/health and /api/admin/insights. There is no sign-up page: administrators create their account on the web app and are promoted there. The console has sections for overview, users, workspaces, payment gateway, marketing (blog and changelog CMS), feedback and settings. Administrators also manage "Admin agent connections" at /mcp/connections/admin on the admin origin.

The web app has no /app-admin or /api/admin routes and no admin link in its navigation. Every admin response carries Cache-Control: no-store, X-Robots-Tag: noindex, nofollow and X-Frame-Options: DENY.

The trusted admin flow

There is no self-service path to become an admin and the seed creates no admin password. The only route is a direct database update by someone who already has access to the D1 database:

pnpm exec wrangler d1 execute APP_DB --local --config apps/web/wrangler.jsonc \
  --command "UPDATE user SET role = 'admin' WHERE normalized_email = 'you@example.com'"

Use --remote --env production instead of --local for a deployed database. The repository documents only the local command, so test the remote form against preview first. Then sign in on the admin origin's /login; a user without the role gets a 403 from /app-admin.

Impersonation

Administrators can impersonate a user from the users section of the admin app. Because the two apps are on different origins, the admin app asks Better Auth's admin plugin for a one-time token and redirects the browser to WEB_APP_URL + /auth/impersonate?token=.... The web route apps/web/app/routes/impersonate.ts redeems that single-use token, re-checks that the acting administrator still has the admin role, is verified and not banned, and that the target is not an administrator, then opens an impersonation session on the web origin. The administrator's own session stays on the admin origin, and /stop-impersonating sends the browser back to ADMIN_APP_URL + /app-admin/users.

Admins cannot impersonate other admins and an impersonation session lasts 30 minutes. While impersonating, every non-GET request is rejected by assertRequestAllowedDuringImpersonation, so the session is read-only. Admins can also issue a user a magic link from the console, either emailed or displayed once; the link points at WEB_APP_URL.

Workspace roles and capabilities

Workspace membership has four roles. authorization.server.ts maps each role to a fixed set of actions:

Actionowneradminmemberreadonly
workspace.readyesyesyesyes
workspace.update, member.invite, member.manage, billing.manage, project.delete, project.approveyesyesnono
workspace.deleteyesnonono
project.create, project.update, comment.create, ai.use, media.uploadyesyesyesno

A route never trusts a workspace ID from the browser. It calls requireWorkspaceContext(env, principal, workspaceSlug), which looks up the caller's membership for the slug in the URL, then authorizeWorkspace(context, action). Repositories receive that WorkspaceContext, so every query is tenant-scoped.

What replaces Row Level Security

D1 has no Postgres RLS. The repository's docs/feature-parity.md describes the layered replacement:

  1. Authentication in every loader and action.
  2. Tenant identity derived from the session and route slug, never from form input.
  3. Capability checks before each mutation.
  4. Tenant predicates in every query and composite foreign keys.
  5. D1 triggers for invariants that must survive application bugs, such as the last-owner rule, the five-active-workspaces cap, the three-active-API-keys cap and atomic credit accounting.
  6. Integration tests in apps/web/test (for example tenant-boundaries.integration.test.ts) that exercise those boundaries against a real D1.

Direct database access stays server-only. Keep new mutations behind the same requireWorkspaceContext and authorizeWorkspace calls.

Agent connections use the same accounts

The User MCP and Admin MCP do not create a second identity system. A tool call reloads the canonical session row, checks that the user is verified and not banned, rejects impersonation sessions, and re-checks workspace membership or the admin role on every request. The User MCP is served by the web Worker and the Admin MCP by the admin Worker. See agent connections.

Environment variables

Every secret and Wrangler variable the web Worker, the admin Worker and the jobs Worker read, where it lives and where to get the value.

Agent connections

The User MCP and Admin MCP in the TanStack + Cloudflare D1 starter: endpoints, tools, OAuth behaviour and where the code lives.

On this page

Where the configuration livesSign-in methodsEmail verification and the local inboxSessionsPlatform roles and the admin appThe trusted admin flowImpersonationWorkspace roles and capabilitiesWhat replaces Row Level SecurityAgent connections use the same accounts