[data-reveal]{opacity:1!important;transform:none!important}
Auth And Tenancy

Built-in authentication

The complete auth system the DMS ships — sessions and token rotation, invite-based signup, login with tenant selection, two-factor, email validation, and password recovery.

The DMS ships a complete authentication system: login screens, session management, two-factor authentication, email validation, and password recovery, all wired end to end. You configure it (Configuration); you don't rebuild it. These flows determine what your users get and where your code can hook in.

For gating your routes and pages with this system — the decorators and the permission model — see Auth & permissions.

The session model

Authentication is JWT-based, with two tokens per session:

  • The access token (default lifetime 1 hour) travels as the Authorization: Bearer header on every request. It carries the user id and the active tenantId.
  • The refresh token (default 30 days) belongs to a session row — one per device/login, recording user agent, browser, OS, IP, and last activity. Refreshing exchanges it for a fresh access token, and rotates the refresh token itself once it is older than 15 days.

Tokens are signed with a per-user secret, derived from the configured jwtSecret and a rotating per-user key. Rotating that key invalidates every outstanding token at once — this is what the profile page's "log out all sessions" does.

Users see their sessions on the built-in profile page — each with device info (browser, OS, IP) — and can revoke any of them (Built-in dashboard). The frontend refreshes expired access tokens transparently; you never handle a 401 yourself as long as you use the authenticated fetch (Auth requests & permissions).

How users enter the platform

There is no open self-service signup in the core DMS. An account comes into existence one of three ways:

  1. Onboarding — on a fresh instance, the first visit funnels to the onboarding wizard, which registers the first admin as platform owner and marks the instance onboarded (Built-in dashboard).
  2. Invites — an admin invites an email from the Members settings page (or your code calls inviteUserToTenant, see Multi-tenant data). The invite email links to /auth/signup?token=…; when the invite carries a first/last name, the link also prefills the signup's name field. The signup consumes the token, creates the account with the invite's roles, attaches it to the tenant, and deletes the invite. The account language comes from the signup form's locale, not from the invite. Invites expire after 7 days.
  3. cms-saas — the optional SaaS module adds real self-registration on top (owners sign up, pick a plan, get their tenant — SaaS mode).

A configured login provider (auth.oauth) can also open the door, but only when the instance sets allowAccountCreation — otherwise a provider identity may sign in an existing account and never create one.

Login

A login attempt verifies the password, then branches:

  1. No tenant membership — the login answers requires_tenant_assignment with a short-lived (15 min) assignment token, and the login screen navigates to /auth/no-workspace. The core DMS does not ship that screen — the cms-saas module provides it as its workspace-creation flow (SaaS mode). The account picker at /auth/accounts is the multi-account switcher and plays no part in tenant assignment.
  2. Two-factor enabled — the login answers requires_2fa with a 5-minute two-factor token and the user's enabled methods; the UI routes to /auth/2fa.
  3. Otherwise — a session is created, both tokens are issued for the user's oldest tenant membership, and a "new login" security notification fires (with an "unknown device" variant when the device cannot be identified from the user agent).

Login providers (OAuth)

When auth.oauth enables GitHub or Google, the auth screens gain a "Continue with …" button above the e-mail form. The round-trip is owned by the backend: the browser-facing layer only carries the redirect and the state cookie, while the authorization code is exchanged inside the instance, with the client secret never leaving it. The identity that comes back is therefore vouched for by the provider, not asserted by the frontend.

The OAuth API endpoints are frontend-server-only: they require a relay secret derived from the instance jwtSecret, handed to the layer through server-side options. The browser-binding CSRF check (the state cookie) lives in the layer that set the cookie, and the relay secret is what guarantees no other caller can stand in for that layer. A module composing its own OAuth-entry flow presents the same secret (runtimeConfig.cms.oauth.relaySecret in its server routes).

That guarantee rests on the frontend server being the only thing that ever receives the secret, which in turn rests on nuxt.bootstrapSecret being configured. Without it the backend withholds every private layer option, and provider login fails in the frontend built from that response.

Once the identity resolves to an account, the flow rejoins the branches above unchanged — requires_tenant_assignment, requires_2fa, or a session. A provider login on a 2FA-enabled account still runs the challenge.

The account it resolves to follows one rule set, applied in order:

  1. a provider account already bound to a user always resolves to that user — provider e-mails change, provider account ids do not;
  2. an account holding the same e-mail is adopted only when the provider marks that e-mail verified and the local account is validated; anything else is refused rather than linked silently;
  3. an unvalidated account holding that e-mail is never adopted as it stands — nobody proved they own it. An invitation addressed to that e-mail reclaims it instead, wiping its password, recovery token, auth key, second factor and provider bindings so nothing the previous holder set survives, exactly as a password signup overwrites an unvalidated account;
  4. an unknown identity creates an account only when allowAccountCreation is on or when the flow carries a valid invitation — an invitation is already the permission to join, exactly as it is for a password signup — and only on a provider-verified e-mail. The account is created without a usable password — password recovery is how such a user later sets one.

Bindings live in their own user_external_identities table, one row per (provider, provider account). linkExternalIdentity() and getExternalIdentities() are exported from the cms-auth interface for modules that create accounts themselves and need to bind the identity inside their own transaction. Product integrations that connect the same provider for other purposes (repository access, deployments) must keep their own storage: these rows only mean "this person can sign in as that account".

A module owning its own registration flow — a SaaS transactional signup, for instance — calls announceRegistration(userModel, user, tenantId), also exported from the cms-auth interface, once its provisioning has committed. It runs everything that must happen when an account has really joined a tenant: the welcome and collaborator notifications, then the USER_REGISTERED hook (Backend services). The CMS password signup and a provider login consuming an invitation go through the same call, so automations never depend on which door the account came through. The call is observational — registration is already complete when it runs, and a failing hook subscriber is logged rather than allowed to fail the request.

A logged-in user can also switch tenants without re-authenticating: membership is verified and both tokens are reissued for the target tenant. The core dashboard ships no UI for it — the workspace switcher comes with the optional cms-saas module (SaaS mode). Switching between separate accounts is a different thing: a frontend feature backed by stored refresh tokens (useMultiAccount).

Two-factor authentication

Users manage 2FA themselves on the built-in profile page. Three methods can verify a login:

MethodHow it works
totpAn authenticator app. Enabling is a two-step enroll: the server issues a pending secret, the user confirms a code.
emailA 6-digit code emailed on demand, valid 10 minutes, single-use; resend is rate-limited to one per 60 seconds.
backupOne of the recovery codes generated at enrollment. Each code is hashed at rest and consumed on use; the profile page can regenerate the set.

Enabling, disabling, and backup-code regeneration all emit security notifications to the account.

Email validation

With auth.mustValidateEmail: true (Configuration), a user must validate their email before @AuthUser-guarded routes accept them. The user requests a verification email (one per 60 seconds; the token is valid 1 day by default) and confirms with the emailed token — the /auth/validate page drives the flow. Admins can force-validate a member from the Members settings page, and invites can skip validation per invitee (skipEmailValidation).

Password recovery

The forgot-password flow is deliberately silent — it answers the same whether or not the email exists, so accounts can't be enumerated. When the account exists it emails a reset token (one per 60 seconds; valid 1 day by default). The /auth/forgot/auth/recover pages validate the token and set the new password, then a "password reset" security notification fires.

Passwords everywhere follow one policy: minimum 8 characters, with at least one uppercase letter, one digit, and one special character (@$!%*?&).

The auth screens

All of these ship as public, hidden pages rendered by the dashboard frontend — you get them without registering anything:

RouteScreen
/authLogin
/auth/signupInvite-based signup (?token=…&email=…)
/auth/2faTwo-factor challenge
/auth/backupBackup-code entry
/auth/validateEmail validation
/auth/forgotRequest a password reset
/auth/forgot-validationReset-token validation
/auth/recoverSet the new password
/auth/recover-successConfirmation
/auth/accountsAccount picker (switch between saved accounts)

A frontend layer can add links under these forms with registerAuthLink (Dashboard chrome).

Emails and security notifications

The flows send transactional email through the HTML render service and your mailer module: an admin-invite email, an email-validation email, a password-reset email, and the 2FA code email. Configure htmlRender.renderEndpoint (and a mailer such as @antelopejs/nodemailer) or these sends fail.

Alongside email, the system posts in-app security notifications — new login (with unknown-device variant), password changed or reset, email changed, 2FA enabled/disabled, backup codes regenerated — under the built-in Security notification subject, which users can toggle in their notification preferences.

Auth in your own frontend pages

Pages you author in a frontend layer opt into the same guard with route meta:

nuxt-layer/app/custom-pages/report.vue
<script setup lang="ts">
definePageMeta({ auth: true });
</script>

auth also accepts navigateAuthenticatedTo / navigateUnauthenticatedTo options to redirect instead of blocking. Backend-described pages don't need this — the catch-all renderer enforces each page's own access options (publicAccess, authOnly, permission) for you.

On the backend, the token helpers behind these flows are exported by the cms-auth interface — see Auth & permissions.