Skip to content

Authentication

Melvin PETIT edited this page Oct 13, 2026 · 5 revisions

Authentication

Authentication runs on Better Auth, backed by the project's own Prisma/PostgreSQL database. Configuration lives in src/lib/auth/server.ts.

Gardik used Auth.js (next-auth v5) until the better_auth migration. AUTH_SECRET and AUTH_URL were replaced by BETTER_AUTH_SECRET and BETTER_AUTH_URL. See Configuration.

Why database-backed sessions

Each sign-in writes a row to the Session table rather than relying on a signed cookie alone. Sessions can therefore be listed, revoked and audited server-side, which is what makes Remediation able to cut an exposed employee's access.

Sign-in methods

Method Plugin Notes
Email and password built in Passwords hashed with bcryptjs, set explicitly on emailAndPassword.password rather than the library default
TOTP two-factor twoFactor Per-user enrollment, with backup codes and lockout after repeated failures
Email OTP twoFactor Second factor delivered by email
Passkeys @better-auth/passkey WebAuthn. A passkey sign-in is complete primary authentication, not a second factor
SSO (OIDC) @better-auth/sso One provider per company, with domain verification

A company chooses which second factors it accepts through Company.allowedAuthMethods (AuthMethod[], default [TOTP]). See PATCH /api/company/auth-policy in the API Reference.

The company auth policy

Two company-level switches drive the policy, both on the Company model:

  • allowedAuthMethods: which of TOTP, EMAIL_OTP, PASSKEY may be used.
  • ssoMandatory: when true, local sign-in is refused and members must come through the identity provider.

User.ssoExempt is the anti-lockout valve. An expired IdP certificate must not lock a whole company out of its own security product, so an exempt user can still sign in locally while ssoMandatory is on. The rule is one function, deniesLocalSignIn in src/lib/sso/policy.ts.

The policy is enforced on the passkey path too. A passkey sign-in creates the session itself, so without an explicit check a company that enabled ssoMandatory to cut someone's access would still have let them in through the passkey button.

Server configuration decisions

src/lib/auth/server.ts configures Better Auth; the policy hook lives in method-policy.ts and the environment switches in server-env.ts.

  • Methods are refused server-side, not hidden. Enrolling TOTP, requesting an email code and registering a passkey are checked against allowedAuthMethods. An email code is requested mid-login, before any full session exists, so the company is resolved from the signed two_factor cookie the same way Better Auth resolves the pending user.
  • No timing equalizer on password sign-in. The library already hashes the supplied password when the address is unknown or has no password; adding a second hash measured 590 ms against 350 ms and recreated the difference the other way.
  • Public sign-up is disabled explicitly. It otherwise only failed because companyId cannot come from a request body, a schema accident that a later change could silently undo.
  • Passwords: bcrypt with 12 rounds like every seed script, 12 to 72 characters (bcrypt ignores everything past 72 bytes).
  • mustChangePassword rides on the session, so the API guard needs no query per request. Setting the flag deletes the user's sessions, so a session saying false was issued after it was cleared.
  • SSO linking does not require a confirmed local email: a pre-provisioned user has none, and the trust comes from the provider's verified domain.
  • SSO is strictly pre-provisioned (disableImplicitSignUp, and requestSignUp is never sent), domain verification is mandatory for linking, and the tenant check runs on every login: on a mismatch the session row is deleted before any cookie is set.
  • appName is "Gardik", which is the issuer an authenticator app shows for the TOTP entry.
  • Rate limiting is stored in the database, so the window is shared by every instance and survives a deploy. It is disabled only when E2E=1 and the base URL is loopback, so a leaked E2E=1 in production does nothing; an unset base URL never counts as loopback.
  • Test-only trusted origins: under Vitest (NODE_ENV=test, which nothing else sets) http://127.0.0.1:* is trusted so the SSO plugin's SSRF guard lets the local stub identity provider through.
  • Passkeys take their relying-party ID and origin from the base URL, so a passkey registered under one host cannot be used under another.

Two-factor enrollment gate

src/lib/auth/two-factor-gate.ts decides whether a signed-in user is routed to enrollment. It refuses to force enrollment on an account with no password, because enabling TOTP requires confirming the current password: a pre-provisioned SSO account has none, so forcing it would strand the user on a form they cannot satisfy. For those accounts the identity provider owns the second factor.

SSO provider management

The SSO plugin exposes provider management to any authenticated session, which would let a Viewer enroll an identity provider on their own company. Gardik gates four plugin paths behind sso:config (src/lib/sso/policy.ts):

/sso/register
/sso/update-provider
/sso/request-domain-verification
/sso/verify-domain

Sign-in and the callback stay open by design, since they are the login path.

Provider records live in SsoProvider, one per company, with domain and domainVerified. The OIDC client configuration is encrypted at rest through a Prisma extension (src/lib/sso/encryption.ts), so it is sealed on write and opened on read without the call sites having to think about it.

POST /api/sso/resolve runs before sign-in: the login page asks whether a typed email's company has a verified provider, so it can send the user to the right place. It is deliberately unauthenticated, since there is no session yet.

Invitations and forced rotation

New users are invited rather than created with a password (src/lib/auth/invitation.ts):

  • The invitation token is single-use and expires after 72 hours (INVITATION_TTL_HOURS).
  • Only a hash of the token is stored, in UserInvitation.
  • The invitee sets a password between 12 and 72 characters. The upper bound is not cosmetic: bcrypt truncates at 72 bytes.

An administrator can force a rotation with POST /api/users/[id]/require-password-change, which sets User.mustChangePassword. A user in that state is refused by the normal guard everywhere except POST /api/account/password, which re-verifies the current password before writing the new one.

The first administrator

A deployed image has no company and no account, and public sign-up is disabled, so nobody can create one. src/lib/auth/bootstrap.ts, wired through src/instrumentation.ts, closes that gap at start-up when BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_INVITE_TOKEN are both set.

It issues an invitation rather than setting a password, so everything above about validation, hashing, verification and the forced second factor applies to it unchanged. The token is supplied by the operator rather than generated, which is what keeps the container from ever printing a secret.

The gate asks whether any account already carries a password, not whether any user exists. The stricter-looking rule is the more fragile one: a link lost before use would lock the operator out permanently. Testing for a password instead lets a restart reissue the invitation, while still closing the door the instant anybody can genuinely sign in.

The other decisions behind it:

  • It runs on every start, and is idempotent. The variables usually stay in the compose file, so each restart reissues the link with a fresh 24-hour window (shorter than a colleague's 72 hours: a deployment finishes in one sitting, and a restart costs nothing). The unredeemed invitation with the same token is deleted first, because UserInvitation.tokenHash is unique and the operator's token never changes.
  • The audit trail stays true. The user is looked up before the upsert, so a restart records an invitation, never a second user.create.
  • The token is never logged. The operator already holds it, and a log line carrying it would outlive the link in any log aggregator.
  • The token must be at least 32 characters, so a guessable value is refused.
  • Nothing throws. An unreachable database, a schema not migrated yet or a half-set pair of variables each become one log line: a failed bootstrap must never turn a running instance into a dead one. No variables at all is silent, since that is the normal state of every running instance.
  • Replicas race safely. Two replicas booting together collide on a unique constraint; the loser logs "Another replica bootstrapped first" instead of a constraint error.

Route protection

src/proxy.ts protects every route except api/auth, static assets and /login. Authorization beyond "is signed in" is permission-based and covered in Roles and Permissions.

Related

Clone this wiki locally