Registry

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

ProviderKindPurpose
Credentials (handoff-credentials)LoginEmail + password against the user table's passwordHash (verified with the app's own password hashing, not a provider SDK). Always available.
GitHubLoginEnabled when AUTH_GITHUB_ID/AUTH_GITHUB_SECRET are set.
GoogleLoginEnabled when AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET are set.
FigmaLinked account, not loginEnabled 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/users explicitly check session.user.role !== 'admin' and either redirect or render a plain "you need administrator access" message. /admin/users itself 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 role column at token-issue time.

Roles & invites

User management (/account/users, admin-only) is invite-based, not open self-registration:

ActionWhat happens
InviteAdmin 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 inviteRe-sends the same reset-password link for a user who hasn't accepted yet (emailVerified still unset). Errors if they've already accepted.
Update roleChange a user between admin/member.
RemoveDeletes 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.

On this page