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.
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
| Capability | Setting group | Plugin |
|---|---|---|
| Email & password | auth.methods.emailPassword | core |
| Magic link (passwordless email link) | auth.methods.magicLink | magicLink |
| Email OTP (one-time code) | auth.methods.emailOTP | emailOTP |
| Phone / SMS OTP | auth.phoneOtp | phoneNumber |
| Social login (Google, Apple, …) | auth.social | core socialProviders |
| Custom OIDC / OAuth2 | auth.oauth.custom | genericOAuth |
| Passkeys (WebAuthn) | auth.passkey | passkey |
| Two-factor: TOTP, backup codes, trusted devices, optional email/SMS second factor | auth.twoFactor | twoFactor |
| Organizations, with optional teams | auth.organization | organization |
| SSO, per-organization SSO included | auth.sso | sso |
| API keys | auth.apiKey | apiKey |
| Anonymous / guest sign-in | auth.methods.anonymous | anonymous |
| JWT / JWKS issuance | always on | jwt + 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 itThe 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.
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.
| Provider | Fields | Apple-only |
|---|---|---|
| Google, GitHub, Apple, … | enabled, clientId, clientSecret | audiences → 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>Magic link
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.
| Field | Default | Notes |
|---|---|---|
enabled | false | Enable phone sign-in |
otpLength | 6 | Clamped to [4, 10] |
expiresIn | 300 | Seconds |
signUpOnVerification | true | Create an account on the first verified number |
await authClient.phoneNumber.sendOtp({ phoneNumber: '+33600000000' })
await authClient.phoneNumber.verify({ phoneNumber: '+33600000000', code: '123456' })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:
| Field | Meaning |
|---|---|
rpId | Relying-party ID, usually your bare domain (example.com) |
rpName | Human name shown in the passkey prompt |
origin | Full 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.
| Field | Default | Effect |
|---|---|---|
allowUserToCreateOrganization | true | Whether end-users can create orgs |
teamsEnabled | false | Adds Better Auth teams and their endpoints |
maxMembers | 0 (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:
| Area | Methods | Important behavior |
|---|---|---|
| Organizations | create, update, delete, list, getFullOrganization, setActive, checkSlug, leave | Calls that omit organizationId use the session's active organization. Creating an organization makes it active unless keepCurrentActiveOrganization is set. |
| Members | listMembers, getActiveMember, getActiveMemberRole, removeMember, updateMemberRole, hasPermission | Roles are owner, admin and member; dynamic roles are not enabled. Pagination and filters are supported by listMembers. |
| Invitations | inviteMember, acceptInvitation, rejectInvitation, cancelInvitation, getInvitation, listInvitations, listUserInvitations | Invitations are addressed by email; accepting one creates membership and is subject to maxMembers. |
| Teams | createTeam, updateTeam, removeTeam, listTeams, setActiveTeam, listUserTeams, listTeamMembers, addTeamMember, removeTeamMember | Available 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.
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.