Authentication
How Convex Auth works in the Next.js + Convex starter: providers, sessions, the admin app and role, and how functions are protected.
Convex Auth owns users and sessions in this kit. The same login is used by the web app, the admin app, server components and agent connections. For the concepts shared across all kits, read the v5 authentication guide first.
Where the configuration lives
| File | What it does |
|---|---|
apps/database/convex/auth.ts | Declares the providers and the convexAuth callbacks |
apps/database/convex/auth.config.ts | Registers CONVEX_SITE_URL as the issuer domain with application id convex |
apps/database/convex/http.ts | Mounts the Convex Auth HTTP routes and the MCP routes |
apps/database/convex/lib/authz.ts | requireUser, requireWorkspaceRole, requirePlatformAdmin and their trusted *Id variants |
apps/database/convex/lib/auth_redirect.ts | Allows redirects only to the SITE_URL and ADMIN_SITE_URL origins |
apps/database/convex/lib/user_profiles.ts | Creates the userProfiles, userSettings and platformRoles rows for a new user |
apps/database/convex/platform.ts | The internal grantAdmin mutation that promotes an account to application administrator |
apps/web/src/proxy.ts | Web middleware built on convexAuthNextjsMiddleware |
apps/admin/src/proxy.ts | Admin middleware: sends signed-out visitors of /app-admin to the admin /login |
apps/web/src/app/convex-client-provider.tsx | Browser Convex client with auth |
Provider credentials and signing keys are deployment environment variables. See environment variables for the list.
Providers
auth.ts registers these providers:
- Password with
verifyset to the email provider andresetset to the password reset provider. Emails are trimmed and lower-cased; passwords must have at least 10 characters. - Email magic link (
id: "email", valid for 15 minutes). The login and sign-up pages callsignIn("password")andsignIn("email"). - Password reset (
id: "password-reset", 15 minutes). The email links to/update-password?mode=reset&email=...&reset_code=...onSITE_URL. - GitHub and Google, only when both
AUTH_GITHUB_IDandAUTH_GITHUB_SECRET(orAUTH_GOOGLE_IDandAUTH_GOOGLE_SECRET) exist on the deployment. The login and sign-up pages ship without provider buttons. Add a button that callssignIn("github")orsignIn("google")when you configure a provider, and register the callback URL on the provider's side using your.convex.siteorigin.
After a user is created or updated, the afterUserCreatedOrUpdated callback calls ensureUserProfile. The redirect callback passes the target through authRedirect, which allows only the SITE_URL origin and, when set, the ADMIN_SITE_URL origin, and falls back to /dashboard on the web origin.
Email verification and the local inbox
Sign-up sends a verification code and link through the email provider. In production the provider posts to the Resend API with AUTH_RESEND_KEY and AUTH_EMAIL_FROM. When the backend has APP_ENV=local or APP_ENV=test, nothing is sent; the message is logged as [local-auth-email] with the code and URL, and convex dev prints it in the pnpm dev terminal.
The password reset provider has no local branch and always needs SITE_URL, AUTH_RESEND_KEY and AUTH_EMAIL_FROM. Set them on your local deployment with pnpm exec convex env set if you want to test resets locally. See emails with Resend for the production setup.
Pages and sessions
Web app (apps/web, port 3000):
| Path | Purpose |
|---|---|
/sign-up, /login | Create an account or sign in. Signed-in visitors are redirected to /dashboard, or to a pending /mcp/authorize?... request |
/forgot-password, /update-password | Request and complete a password reset |
/onboarding | Accept terms, complete the profile, create the first workspace (convex/onboarding.ts) |
/dashboard | Entry point after login |
/auth/impersonate | Receives a single-use impersonation handoff from the admin app and starts a 15-minute support session |
/logout | Ends the session |
Admin app (apps/admin, port 3002):
| Path | Purpose |
|---|---|
/login | Password or magic-link sign-in for administrators. Signed-in visitors go to /app-admin, or to a pending /mcp/authorize?... request |
/auth/callback | Returns magic-link and OAuth callbacks to /app-admin |
/app-admin | The admin console. Anyone without the admin platform role gets the unauthorized page |
/mcp/authorize | Consent page for the Admin MCP |
apps/web/src/proxy.ts redirects unauthenticated requests to /login for /dashboard, /settings, /profile, /workspace, /project, /home, /user, /logout and /onboarding. It also adds a request id header and sets X-Frame-Options: DENY, a frame-ancestors 'none' policy and Referrer-Policy: no-referrer on /mcp/* pages. apps/admin/src/proxy.ts does the same for /app-admin. Both middlewares are coarse gates; pages and Convex functions still check access.
Server components read the session with convexAuthNextjsToken() and pass it to fetchQuery or fetchMutation. Client components use useQuery and useMutation from convex/react. Sessions are rows in the Convex Auth authSessions table. The admin app has its own origin-scoped session, so signing in on localhost:3002 does not sign you in on localhost:3000. MCP connections are bound to the session that approved them, so signing out of that session also ends those connections.
Legacy Supabase auth routes
/auth/callback, /auth/confirm and /confirm-delete-user/[token] in the web app still call the Supabase client, as do apps/web/src/middlewares/ (unused; proxy.ts is the real middleware) and the Supabase-era e2e helpers. Convex Auth does not use these routes. Remove or port them before shipping.
Becoming an application administrator
Application-wide administration is a row in the platformRoles table with role: "admin". ensureUserProfile gives every new user role: "user". Nothing in the sign-up flow promotes an account; the operator does it from the CLI.
Sign up normally in the web app, then run the internal mutation from the repository root against the selected deployment:
pnpm exec convex run platform:grantAdmin '{"email":"you@example.com"}'platform:grantAdmin (apps/database/convex/platform.ts) looks the account up by normalised email, refuses unknown, disabled or deleted accounts, inserts or patches the platformRoles row, and returns { userId, changed }. It is idempotent and an internalMutation, so it is never callable from a client. For a production deployment add --prod: pnpm exec convex run --prod platform:grantAdmin '{"email":"..."}'. platform.test.ts covers the promote and idempotent path and the unknown and disabled account rejections.
Then sign in to the admin app at /login on the admin origin. The web app reads the role through api.users.viewer (isAppAdmin); the admin app's /app-admin layout calls serverGetLoggedInUserClaims() and unauthorized() for everyone else.
The admin app
Platform administration no longer lives in apps/web. The admin console is /app-admin in apps/admin, with users, workspaces, marketing content (blog, changelog, authors, tags), feedback moderation, the payment gateway, settings and /app-admin/connections for the Admin MCP. The demo admin is at https://nextjs-convex-admin.usenextbase.com. Being a workspace owner never grants application administration.
The two apps share the Convex deployment and every privileged operation is authorised in Convex, not in the browser. ADMIN_SITE_URL on the deployment tells the backend which second origin to trust for auth redirects and admin consent. The admin app's NEXT_PUBLIC_WEB_APP_URL and the web app's NEXT_PUBLIC_ADMIN_APP_URL let each app link to the other.
Impersonation. From the admin user list choose Impersonate in web app and enter a support reason. Convex requires an active administrator session, creates a one-use token, stores only its SHA-256 hash and writes the reason to the audit log. The handoff expires in 60 seconds and travels in the URL fragment to NEXT_PUBLIC_WEB_APP_URL + /auth/impersonate, where the web app exchanges it for a 15-minute target-user session. Expired or replayed links, demoted administrators and administrator targets are rejected; the admin session stays signed in on its own origin. End support session in the web app signs the temporary session out. The repository's docs/separate-admin-app.md has the full description.
What is protected and how
Convex has no row-level security; authorization is code in each function. The rules the kit follows:
- Every public
queryormutationstarts with a check fromconvex/lib/authz.ts.requireUserresolves the Convex Auth user and rejects disabled or deleted profiles.requireWorkspaceRole(ctx, workspaceId, minRole)checksworkspaceMembersagainst the role orderreadonly < member < admin < owner.requirePlatformAdminchecksplatformRoles. - Functions never accept a
userIdfrom the client. TherequireUserId,requireWorkspaceRoleIdandrequirePlatformAdminIdvariants exist for trusted internal callers such as MCP tools. - Shared domain helpers, for example
createProjectForActorinconvex/projects.ts, enforce the same rules for the website and for agents. Creating a project needs at least thememberrole. - Anything not called from the browser is an
internalQueryorinternalMutation. MCP tool handlers andplatform:grantAdminare internal functions. - Errors are thrown with
appError(code, message)fromconvex/lib/errors.ts. - Backend tests in
apps/database/convex/*.test.tsuseconvexTestwitht.withIdentity({ subject: "<userId>|<sessionId>" })and cover the unauthorized and wrong-role paths.
Server actions in both apps go through authActionClient and adminActionClient from each app's src/lib/safe-action.ts. Admin layouts use serverGetLoggedInUserClaims() and unauthorized(). UI visibility is a convenience, not the security boundary.