Auth (NextAuth)
How registry web-app authentication works for admins and stakeholders.
The registry's own UI, everything a signed-in human browses, from foundations to /admin/*,
is authenticated by NextAuth (Auth.js v5), a session-based model distinct from the bearer-token
auth the CLI and MCP use. If you're looking for that instead, see
MCP → Authentication and API → Auth & scopes.
Sessions are always JWT
Regardless of which provider signs a user in, NextAuth is configured with session: { strategy: 'jwt' }, never database sessions. This is deliberate: Edge middleware (the /admin/* route
gate) can only read a JWT via getToken(), not a database-backed session, so database sessions
would silently break admin access whenever an OAuth provider was configured. AUTH_SECRET is the
signing key for these tokens, see Environment variables.
Providers
| Provider | Kind | Purpose |
|---|---|---|
Credentials (handoff-credentials) | Login | Email + password against the user table's passwordHash (verified with the app's own password hashing, not a provider SDK). Always available. |
| GitHub | Login | Enabled when AUTH_GITHUB_ID/AUTH_GITHUB_SECRET are set. |
| Login | Enabled when AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET are set. | |
| Figma | Linked account, not login | Enabled when AUTH_FIGMA_ID/AUTH_FIGMA_SECRET are set. Used to attach a Figma account for API access (fetch/audit flows), not to sign in, see below. |
A Drizzle adapter (account/session storage) is wired in automatically whenever any OAuth provider is configured, for account linking, but sessions themselves still come from the JWT, never the adapter's session table.
Linked-account provider: Figma
The Figma provider is a plain OAuth client registered directly against Figma's OAuth endpoints
(no NextAuth built-in for Figma). Its access/refresh tokens are stored in the account table.
One quirk worth knowing: NextAuth skips linkAccount on a reconnect if a row already exists for
that provider+account, which would normally leave a stale token in place, the jwt callback
explicitly overwrites the stored access_token/refresh_token/expires_at/scope on every
Figma OAuth completion so a reconnect actually refreshes the token.
Roles
user.role is 'admin' or 'member' (default). Role gates two different things:
- Registry UI access: pages under
/admin/*(ai-cost,builds,integrations,reference) and/account/usersexplicitly checksession.user.role !== 'admin'and either redirect or render a plain "you need administrator access" message./admin/usersitself just redirects to/account/users, user management lives there. - CLI/MCP OAuth scopes: a completely separate mechanism. An admin's device/connector login
receives every scope in the vocabulary; a member receives a narrower default set. See
API → Auth & scopes; this is unrelated to the
session role above, just driven by the same
rolecolumn at token-issue time.
Roles & invites
User management (/account/users, admin-only) is invite-based, not open self-registration:
| Action | What happens |
|---|---|
| Invite | Admin enters an email + role (admin/member). A user row is created with no password set; an invite email with a /reset-password?token=... link is sent via Resend (or logged only, if RESEND_API_KEY is unset). |
| Resend invite | Re-sends the same reset-password link for a user who hasn't accepted yet (emailVerified still unset). Errors if they've already accepted. |
| Update role | Change a user between admin/member. |
| Remove | Deletes the user row. |
The very first user isn't invited; it's created by First-admin setup
(the /setup flow, or the db:bootstrap/db:seed scripts), since there's no existing admin to
send an invite yet.
Workspace mode has no session auth at all
Outside registry mode (no DATABASE_URL), auth() returns null unconditionally; there's no
sign-in, no roles, no /account//admin UI gating. Auth only exists once a registry is
deployed with Postgres. See Model for the DATABASE_URL switch this
follows.