Browse documentation

Motherbase Auth is built on Better Auth. Each project gets its own auth instance, assembled at runtime from the auth.* settings groups. Enabling a method or plugin in Project Studio → Authentication rebuilds the instance live — no redeploy.

Note

Auth is config-driven. The server reads the auth.* settings groups and turns providers and plugins on or off per project. Saving in the console rebuilds the auth instance in-process; in-flight requests keep their handler.

What you get

CapabilitySetting groupPlugin
Email & passwordauth.methods.emailPasswordcore
Magic link (passwordless email link)auth.methods.magicLinkmagicLink
Email OTP (one-time code)auth.methods.emailOTPemailOTP
Phone / SMS OTPauth.phoneOtpphoneNumber
Social login (Google, Apple, …)auth.socialcore socialProviders
Custom OIDC / OAuth2auth.oauth.customgenericOAuth
Passkeys (WebAuthn)auth.passkeypasskey
Two-factor: TOTP, backup codes, trusted devices, optional email/SMS second factorauth.twoFactortwoFactor
Organizations, with optional teamsauth.organizationorganization
SSO, per-organization SSO includedauth.ssosso
API keysauth.apiKeyapiKey
Anonymous / guest sign-inauth.methods.anonymousanonymous
JWT / JWKS issuancealways onjwt + bearer

Email-password, magic link and email OTP default on. Everything else is off until it is enabled and configured — phone OTP additionally needs a working SMS provider before the plugin is mounted at all.

Anonymous sign-in is a real, opt-in session. Enable auth.methods.anonymous in Studio; the server creates a user with is_anonymous = true, and RLS can distinguish that user from an unauthenticated request. A guest can later become a permanent account with updateUser({ email }) or linkIdentity() without changing its user id. This is distinct from a signed-out request, which still uses the PostgreSQL anon role.

Every account gets a username even though Better Auth does not set one: the user.create.before hook (apps/server/auth/instance.ts) fills it from UserService.getRandomUsername() when the sign-up does not supply one, across every method — password, social, magic link, OTP, passkey. A client-supplied username is kept as is, and a generation failure leaves the column null rather than failing the sign-up. Passwordless methods (magic link, email OTP, password reset) require SMTP — configure it in Project Studio → Settings → Email / SMTP. See Notifications for SMTP, SMS and template handling.

Sessions and tokens

Better Auth issues a single session token, not an access/refresh pair. Send it as a bearer token:

Authorization: Bearer <session-token>

The server verifies the session (cookie or bearer), then reloads the full user record by id on every request. Role and status are always read live from the database — never trusted from the token. A soft-deleted or deactivated account (status !== 'active') is rejected with 403.

For downstream services that want a verifiable JWT, the jwt plugin issues one and publishes a JWKS:

GET /api/auth/token   # mint a signed JWT for the current session
GET /api/auth/jwks    # public keys to verify it
Note

The JWKS private key is stored unencrypted (self-hosted, trusted DB) so it is decoupled from the auth secret — rotating JWT_SECRET will not make existing keys undecryptable.

Email + password with the SDK

const { data: signedUp, error: signUpError } = await client.auth.signUp({
  email: 'user@example.com',
  password: 'secret123',
  username: 'alice',
})
 
const { data: signedIn, error: signInError } = await client.auth.signInWithPassword({
  email: 'user@example.com',
  password: 'secret123',
})
 
const { data: userResult } = await client.auth.getUser()      // GET /api/auth/get-session
const { data: sessionResult } = await client.auth.getSession() // local, no network
 
await client.auth.signOut()

signUp and signInWithPassword return { user, session }; session.accessToken is the bearer token. Passwords are hashed with argon2 (bcrypt hashes are verified for import compatibility).

Auth state listener:

const { data: { subscription } } = client.auth.onAuthStateChange((event, session) => {
  // event: 'INITIAL_SESSION' | 'SIGNED_IN' | 'SIGNED_OUT' | 'TOKEN_REFRESHED'
  //      | 'USER_UPDATED' | 'MFA_CHALLENGE_VERIFIED'
})
 
subscription.unsubscribe()

The SDK attaches the session token automatically. On a 401 it revalidates the session once against /api/auth/get-session; if the session is gone it clears local state and emits SIGNED_OUT.

Note

The Motherbase SDK covers email/password, email OTP, password reset, OAuth, anonymous sign-in, CAPTCHA tokens, PKCE code exchange, native id-token sign-in, linked identities, two-factor (client.auth.mfa.*), passkeys (client.auth.passkey.*), organizations and teams (client.auth.organization.*), runtime SSO provider management (client.auth.sso.*), SSO sign-in (client.auth.signInWithSSO) and secret-key user administration (client.auth.admin.*). Magic links and API-key issuance still have no dedicated Motherbase namespace: use the Better Auth client for those endpoints. The SDK compatibility matrix is the exact list.

Social login

Enable a provider in Project Studio → Authentication → Social providers (auth.social). A provider is only assembled when it is enabled and has both clientId and clientSecret.

ProviderFieldsApple-only
Google, GitHub, Apple, …enabled, clientId, clientSecretaudiences → appBundleIdentifier

Sign-in and callback:

// Better Auth client pointed at your project domain
await authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' })
Callback URL to register at the provider:
https://<your-project-domain>/api/auth/callback/<provider>

The Authentication panel in Studio shows per-provider readiness — enabled, configured, and the exact callback URL — so missing: ['clientSecret'] never surprises you in production.

Custom OIDC / OAuth2

For any provider that isn't a first-class social provider, add a generic OIDC/OAuth2 config under Authentication → Custom OIDC (auth.oauth.custom). Each entry needs a providerId, a clientId, and either a discoveryUrl or both authorizationUrl + tokenUrl.

Callback URL:
https://<your-project-domain>/api/auth/oauth2/callback/<providerId>

Passwordless sign-in by emailed link. Requires SMTP.

await authClient.signIn.magicLink({ email: 'user@example.com', callbackURL: '/dashboard' })

The server emails a one-time sign-in URL; opening it establishes the session.

Email OTP

Passwordless one-time code, also over SMTP. Better Auth exposes the standard email-otp routes under /api/auth:

await authClient.emailOtp.sendVerificationOtp({ email: 'user@example.com', type: 'sign-in' })
await authClient.signIn.emailOtp({ email: 'user@example.com', otp: '123456' })

Codes are delivered as ${APP_NAME} verification code. Rate limits apply per IP.

Phone / SMS OTP

Enable auth.phoneOtp. The plugin is only mounted when the setting is on and an SMS provider is configured — see Notifications. With no SMS provider the setting has no effect, silently, because there is nowhere to send the code.

FieldDefaultNotes
enabledfalseEnable phone sign-in
otpLength6Clamped to [4, 10]
expiresIn300Seconds
signUpOnVerificationtrueCreate an account on the first verified number
await authClient.phoneNumber.sendOtp({ phoneNumber: '+33600000000' })
await authClient.phoneNumber.verify({ phoneNumber: '+33600000000', code: '123456' })
Note

User.email is a required unique column, so a phone-only sign-up gets a stand-in address of the form 33600000000@phone.local until the user attaches a real one. Do not treat that value as a deliverable address, and do not key application logic on it.

Passkeys (WebAuthn)

Enable auth.passkey. Configure the relying party so browsers scope credentials correctly:

FieldMeaning
rpIdRelying-party ID, usually your bare domain (example.com)
rpNameHuman name shown in the passkey prompt
originFull origin (https://app.example.com)
// Register a passkey for the signed-in user
await authClient.passkey.addPasskey()
 
// Sign in with a passkey
await authClient.signIn.passkey()

Studio readiness flags missing: ['rpId'] if the plugin is on but rpId is unset.

Two-factor (TOTP)

Enable auth.twoFactor. Users enroll an authenticator app and verify a TOTP on sign-in.

await authClient.twoFactor.enable({ password: 'secret123' }) // returns TOTP URI + backup codes
await authClient.twoFactor.verifyTotp({ code: '123456' })

Organizations and teams

The organization plugin is loaded by default and can be disabled with auth.organization.enabled. It is the end-user tenant model inside one project; it is separate from the Platform Console's organizations, which group projects for operators.

FieldDefaultEffect
allowUserToCreateOrganizationtrueWhether end-users can create orgs
teamsEnabledfalseAdds Better Auth teams and their endpoints
maxMembers0 (unlimited)Seat cap, enforced on direct adds, invitation accepts, and SSO auto-provisioning

teamsEnabled was previously labelled "Orgs can register their own SSO" (allowOrgSso), which never affected SSO. The old key is still read.

const { data: org } = await client.auth.organization.create({
  name: 'BigCorp',
  slug: 'bigcorp',
})
 
await client.auth.organization.inviteMember({
  organizationId: org!.id,
  email: 'teammate@bigcorp.com',
  role: 'member',
})
 
await client.auth.organization.setActive({ organizationId: org!.id })
const { data: current } = await client.auth.organization.getFullOrganization()

The SDK returns { data, error } like the rest of the Motherbase client. The organization namespace includes:

AreaMethodsImportant behavior
Organizationscreate, update, delete, list, getFullOrganization, setActive, checkSlug, leaveCalls that omit organizationId use the session's active organization. Creating an organization makes it active unless keepCurrentActiveOrganization is set.
MemberslistMembers, getActiveMember, getActiveMemberRole, removeMember, updateMemberRole, hasPermissionRoles are owner, admin and member; dynamic roles are not enabled. Pagination and filters are supported by listMembers.
InvitationsinviteMember, acceptInvitation, rejectInvitation, cancelInvitation, getInvitation, listInvitations, listUserInvitationsInvitations are addressed by email; accepting one creates membership and is subject to maxMembers.
TeamscreateTeam, updateTeam, removeTeam, listTeams, setActiveTeam, listUserTeams, listTeamMembers, addTeamMember, removeTeamMemberAvailable only when teamsEnabled is on. Teams belong to an organization; team membership cannot cross organizations.

Use hasPermission for a capability check instead of assuming that an organization member can perform every action. Organization endpoints require a signed-in user and return a structured error when the caller lacks the required role or the organization is full.

Per-organization SSO

Each end-user organization can bring its own identity provider (Okta, Entra ID, Google Workspace, any OIDC/SAML IdP). This is tenant data managed at runtime by the org's own admins — not project config. The project-wide auth.sso switch must still be enabled.

Turn it on: enable Auth · SSO (auth.sso). That is the only switch that governs SSO, per-organization SSO included — nothing in Auth · Organizations enables or disables it. Leave the organization plugin enabled so users have orgs to attach providers to.

An org admin registers their IdP, scoped to an organizationId, through the Motherbase SDK:

const { data, error } = await client.auth.sso.register({
  providerId: 'bigcorp-okta',
  issuer: 'https://bigcorp.okta.com',
  domain: 'bigcorp.com',            // email domain that maps to this provider
  organizationId: '<org-id>',
  oidcConfig: {
    clientId: '<client-id>',
    clientSecret: '<client-secret>',
    discoveryEndpoint: 'https://bigcorp.okta.com/.well-known/openid-configuration',
    scopes: ['openid', 'email', 'profile'],
  },
})
// data.redirectURI is the callback URL to register at the IdP.

Members then sign in through their org's IdP:

// Resolved from the email domain…
await client.auth.signInWithSSO({ email: 'jane@bigcorp.com', options: { redirectTo: '/dashboard' } })
// …or explicitly by provider id:
await client.auth.signInWithSSO({ providerId: 'bigcorp-okta', options: { redirectTo: '/dashboard' } })
Callback URL to register at the IdP:
https://<your-project-domain>/api/auth/sso/callback/<providerId>

SAML is registered the same way with a samlConfig block instead of oidcConfig. The provider management namespace also exposes listProviders(), getProvider({ providerId }), updateProvider(...) and deleteProvider({ providerId }). Secrets are not returned when providers are listed or fetched. Provider records live in the ssoProvider table keyed by providerId; a provider with no organizationId is project-wide, while one with an organizationId is restricted to that organization's members.

Note

The Platform Console (operator) login is deliberately not affected by any of this. It stays email + password only.

API keys

Enable auth.apiKey to let users mint long-lived API keys for server-to-server access.

const { data } = await authClient.apiKey.create({ name: 'ci-pipeline' })
// send as: Authorization: Bearer <api-key>

Roles

The user role field defaults to USER. The middleware loads the user record on every request and exposes the live value to PostgreSQL RLS as auth.jwt() ->> 'user_role'. Promote or demote from Project Studio → Authentication → Users or the CLI.

CREATE POLICY "admin only"
ON internal_report
FOR ALL
TO authenticated
USING (auth.jwt() ->> 'user_role' = 'ADMIN')
WITH CHECK (auth.jwt() ->> 'user_role' = 'ADMIN');

See RLS for the full auth.uid() / auth.jwt() / anon / authenticated model.

Readiness status

GET /api/admin/auth/status (admin only) returns per method/provider/plugin readiness — enabled, configured, what's missing, and the callbackUrl. Studio's Authentication panel renders this so "what's on" reads from one source of truth and never contradicts the toggles.

Rate limiting

Sign-in, sign-up, OTP, magic-link, and password-reset routes are rate-limited per IP. Configure limits in Project Studio → Settings.