Authentication
How it works
Section titled “How it works”Admin sign-in runs on Better Auth, embedded in Kide. Kide owns the model — the users collection, roles, access rules, the database schema and the sign-in screens — and Better Auth owns the methods: credential checks, sessions, OAuth/OIDC, TOTP and WebAuthn. Everything is configured in cms.config.ts; there is no settings UI.
| Method | Config | Default |
|---|---|---|
| Email and password | admin.auth.password.enabled |
on |
| Forgot password (email reset link) | admin.auth.password.forgotPassword |
on |
| Single sign-on (Microsoft, Google, any OIDC) | admin.auth.sso.providers |
none |
| Authenticator app (TOTP) + backup codes | admin.auth.mfa.totp |
off |
| Passkeys | admin.auth.mfa.passkeys |
off |
There is no public sign-up. The first administrator is created through setup or the CLI; everyone else is invited by an administrator, or — for SSO with provisioning: "jit" — created on first sign-in from an allowed email domain.
Every signed-in user can open Account & security from the user menu (/admin/account) to change their password, set up an authenticator app, manage passkeys, see linked SSO accounts and sign out other devices.
KIDE_AUTH_SECRET
Section titled “KIDE_AUTH_SECRET”Production builds need a secret to sign session cookies:
openssl rand -base64 32Set it as KIDE_AUTH_SECRET in the deployment environment (.env on Node, wrangler secret put KIDE_AUTH_SECRET on Cloudflare). Without it, admin sign-in fails with a message naming the variable. Development falls back to a fixed secret and logs a warning. Rotating the secret signs everyone out.
On first run with no admin, visiting /admin redirects to /admin/setup, where you create the initial admin account.
pnpm cms:admin creates an admin from the terminal — useful on a fresh deployment, and as the way back in if SSO is misconfigured.
Inviting users
Section titled “Inviting users”- An admin creates a user at
/admin/users/newwith an email and role. - Kide sends an invitation if email is configured (
RESEND_API_KEY), and always shows a copyable link in the admin. - With password sign-in on, the link opens
/admin/invite?token=…, where the user sets their name and password. The token is valid for 7 days, stored only as a hash, and consumed atomically. - With password sign-in off, or for an email at a domain bound to an SSO provider, the link is simply the sign-in page — the invited user continues with their organization account.
Password recovery
Section titled “Password recovery”Users request a reset at /admin/forgot-password. The response is the same whether or not the address exists. The reset link is valid for one hour, single-use, and stored only as a hash. Completing a reset signs the user out everywhere and sends them back to the sign-in page, so a second factor still applies.
Accounts that only sign in with SSO, and users at a domain bound to an SSO provider, get no reset email — their identity provider, and its offboarding, stays the only way in.
Without a configured email adapter there is nothing to send the link with: production hides the “Forgot password?” link and the page says reset by email isn’t available, while development prints the reset link in the dev server’s terminal. Turn recovery off entirely with:
admin: { auth: { password: { forgotPassword: false } } },Single sign-on
Section titled “Single sign-on”Add identity providers under admin.auth.sso.providers. Each one becomes a “Continue with …” button on the sign-in page.
export default defineConfig({ admin: { auth: { sso: { providers: [ { id: "microsoft", label: "Microsoft", type: "microsoft", tenantId: "6f1c2c4e-0000-4000-8000-000000000000", allowedDomains: ["client.fi"], }, { id: "google", label: "Google", type: "google", hostedDomain: "agency.fi", }, { id: "keycloak", label: "Intranet", type: "oidc", issuer: "https://id.client.fi/realms/main", allowedDomains: ["client.fi"], }, ], }, }, }, collections: [users, pages],});| Type | For | Required |
|---|---|---|
microsoft |
Microsoft Entra ID, single tenant | tenantId (the directory GUID — multi-tenant endpoints are rejected) |
google |
Google Workspace or Google accounts | — (hostedDomain recommended) |
oidc |
Okta, Keycloak, Auth0, Authentik, Idura, and any other OpenID Connect provider | issuer |
Credentials. Register Kide as a web application at the provider with the redirect URI https://<your-site>/api/cms/auth/callback/<id>. Kide reads the client ID and secret from KIDE_SSO_<ID>_CLIENT_ID and KIDE_SSO_<ID>_CLIENT_SECRET (the id uppercased, dashes as underscores), or from clientId / clientSecret in the config. Prefer the environment for the secret.
Who can sign in.
provisioning: "invite-only"(default) — the user must already exist in Kide. SSO links to them by email the first time, and by the provider’s stable user id after that, so a later email change at the provider doesn’t matter.provisioning: "jit"— a Kide user is created on first sign-in, withrole(default"editor"). RequiresallowedDomains; other domains are refused.
allowedDomains lists the email domains this provider is authoritative for. Emails at those domains count as verified for linking even when the provider doesn’t say so — Entra usually doesn’t. Outside them, Kide links only to addresses the provider marks verified, and never to an unverified one. List only domains the provider really controls. For google, hostedDomain plays this role and is trusted only for managed Workspace accounts.
Domain-bound users must use the provider. Users with an email at a provider’s allowedDomains sign in only through it: the password form, password reset and password invites are refused for them (“Your organization signs in with …”), and any session they hold is subject to the re-check below. Set enforce: false on the provider to let such users keep a password as well. Keep a break-glass admin whose email is outside the bound domains (pnpm cms:admin), in case the provider is ever misconfigured.
Leavers lose access. Kide re-checks every SSO user with their provider at most every sso.verifyEveryMinutes (default 60), by refreshing the provider’s token in the background — editors don’t notice. When the provider refuses (the account is disabled, deleted or its access revoked), all of the user’s sessions end at once and they see “Your organization account no longer has access.” If the provider can’t be reached, nothing happens and the check retries a few minutes later. Users of a provider that issued no refresh token are instead sent back through the provider at that interval — usually a single click while their provider session is alive — and return to the page they were on.
sso: { providers: [/* … */], verifyEveryMinutes: 30 },The default scopes ask for a refresh token (offline_access; access_type=offline for Google). If you override scopes, keep offline_access where the provider supports it. Refresh tokens are stored encrypted with KIDE_AUTH_SECRET.
Roles from the provider. mapRole receives the ID-token claims on every sign-in and every re-check; return a role to set it, or null to leave it alone:
{ id: "microsoft", label: "Microsoft", type: "microsoft", tenantId: "…", allowedDomains: ["client.fi"], mapRole: (claims) => ((claims.roles as string[] | undefined)?.includes("Kide.Admin") ? "admin" : "editor"),}A role the users collection doesn’t define is ignored with a warning.
SSO only. Set password: { enabled: false } and the sign-in page shows only the provider buttons. Keep pnpm cms:admin in mind as the way back in.
SAML. Not built in. Put a broker that speaks SAML to the identity provider and OIDC to Kide in between — Entra, Okta, Keycloak and Auth0 can all do this.
Two-factor authentication
Section titled “Two-factor authentication”admin: { auth: { mfa: { totp: true } } },Password users can then enroll an authenticator app on their account page: scan a QR code, save ten backup codes, confirm with a code. After that, signing in with a password asks for a 6-digit code (or a backup code); “Trust this device” skips the step for 30 days. Repeated wrong codes lock the second factor temporarily.
Make it mandatory with require, for everyone (true) or for listed roles:
admin: { auth: { mfa: { totp: true, require: ["admin"] } } },A password user of a required role who hasn’t enrolled is sent to the account page until they do, and can’t turn it off. SSO users are exempt — their identity provider enforces its own MFA.
Passkeys
Section titled “Passkeys”admin: { auth: { mfa: { passkeys: true } } },Users add passkeys on their account page (adding one requires a sign-in from the last 24 hours) and get a “Sign in with a passkey” button on the sign-in page. A passkey is a complete sign-in on its own — it doesn’t ask for a TOTP code. Passkeys are bound to the site’s hostname: one registered on localhost won’t work in production.
Configuration reference
Section titled “Configuration reference”| Option | Default | Notes |
|---|---|---|
password.enabled |
true |
Email and password sign-in. |
password.forgotPassword |
true |
Needs an email adapter. |
mfa.totp |
false |
Authenticator apps and backup codes. |
mfa.passkeys |
false |
WebAuthn passkeys. |
mfa.require |
false |
true or a list of roles. Needs mfa.totp. |
sso.providers |
[] |
See Single sign-on. Per provider: enforce (default true) binds allowedDomains users to the provider. |
sso.verifyEveryMinutes |
60 |
How often SSO users are re-checked with their provider (minimum 5). |
sessionDays |
30 |
Session lifetime; active sessions renew daily. |
provider |
"local" |
A custom provider object replaces the built-in auth entirely — see below. |
betterAuth |
— | (options) => options to adjust the generated Better Auth options, e.g. add a plugin. Tables the plugin needs are picked up by cms:generate, and its HTTP endpoints are served under /api/cms/auth/. Better Auth’s built-in endpoints that Kide doesn’t use (sign-up, email change, account deletion) stay closed. |
The failed-login limit is admin.rateLimit; see Rate limiting. Enabling TOTP or passkeys adds tables: run pnpm cms:generate && pnpm cms:push (Node) or generate and apply a migration (D1).
Custom provider
Section titled “Custom provider”When sign-in happens elsewhere entirely — Cloudflare Access, a reverse proxy that sets identity headers, an existing session from your own app — replace the built-in auth with a provider object. Kide then only asks it for the current user:
import { customAuth } from "@kidecms/core";
admin: { auth: { provider: customAuth({ kind: "custom", getSession: async (request) => verifyAccessJwt(request), // → { id, email, name?, role? } | null loginUrl: "https://login.example.com", }), },},Auth collection
Section titled “Auth collection”The users collection is marked auth: true and needs name and email fields and timestamps:
defineCollection({ slug: "users", labels: { singular: "User", plural: "Users" }, auth: true, fields: { email: fields.email({ required: true, unique: true }), name: fields.text({ required: true }), role: fields.select({ options: ["admin", "editor"], defaultValue: "editor" }), password: fields.text({ admin: { hidden: true } }), },});It gets stricter default access rules than ordinary collections. A password written through the collection API (cms.users.create/update, seed data) is hashed and stored as the user’s sign-in credential — never on the user row, in version snapshots or in session data — and changing it signs the user out everywhere. Emails are stored lowercased. Better Auth’s own per-user state (email verification, TOTP flag) lives in _auth* system columns on the same table; its sessions, linked accounts and tokens live in cms_auth_* tables.
Users created by JIT provisioning are inserted by Better Auth with only its own fields, so any other required field on the users collection needs a defaultValue.
Middleware
Section titled “Middleware”The middleware in src/cms/middleware/auth.ts (injected with order: "pre") protects admin pages and CMS API routes, applies security headers, performs the same-origin check for state-changing browser requests, and enforces mfa.require. Public site routes are unaffected. Machine endpoints such as cron and webhooks authenticate separately.
Roles are plain strings stored on the user document. Access rules defined in each collection config use them to gate operations. There’s no built-in role hierarchy. You define what each role can do. See Access Control for details.
Exact token lifetimes, hashing parameters, and rate limits are in Security.