Authentication
How it works
Section titled “How it works”Kide includes a deliberately small, first-party authentication system for the admin:
- Passwords are salted and hashed with PBKDF2 using the Web Crypto API.
- Random session tokens are sent only in an HttpOnly cookie. The database stores a SHA-256 reference, not the reusable token.
- Cookies use
SameSite=Strictand useSecurein production. - Sessions have an absolute 30-day expiry and are validated server-side on every request.
- The current user and role are loaded from the user record on every request, so deleting a user or changing their role takes effect immediately.
- Failed logins are throttled by both client IP and email using database-backed counters.
- State-changing admin requests require a positive same-origin check.
There is no public sign-up route. The first administrator is created through setup or the CLI; later accounts are created by an administrator through invitations.
Supported scope
Section titled “Supported scope”Kide owns the authentication needed for ordinary CMS administration:
- initial administrator setup
- email and password login
- server-side sessions and logout
- administrator-created invitations
- forgot-password recovery when email is configured
- local users and CMS roles
The config surface goes further than the implementation. admin.auth accepts provider: "local" | "better-auth" | "workos" (or a custom provider object), mfa flags for TOTP, backup codes, and passkeys, and sso.providers, and an SSO start route is injected. These are experimental stubs: no callback route exists and the provider adapters are not shipped, so setting them changes nothing functional. Local email and password login is the only working provider today.
Architecture policy
Section titled “Architecture policy”Kide maintains a deliberately limited first-party authentication system for password-based CMS access. Advanced federation or authenticator features are not added one by one. A real deployment requirement for them triggers a separate identity-provider integration that keeps authentication identity distinct from Kide authorization.
This boundary keeps one system responsible for each security decision. The built-in flow owns local credentials and sessions; Kide’s user record owns CMS membership and roles. A future external identity provider should authenticate a stable provider subject and map it to a local Kide user or membership. It should not share ownership of Kide’s password or session records.
Reconsider the built-in model when a real deployment requires capabilities such as enterprise SSO, phishing-resistant MFA, multiple linked credentials, or centrally managed identities. Any such adoption needs its own threat model, account migration and rollback plan, real-provider validation, and tests on both Node.js/SQLite and Cloudflare/D1.
On first run with no users, visiting /admin redirects to /admin/setup where you create the initial admin account.
An admin user can also be created from the terminal: pnpm cms:admin prompts for name, email, and password and inserts an admin account directly. Useful when the browser setup flow is inconvenient, or for creating additional admins without the invite flow.
Inviting users
Section titled “Inviting users”After the initial admin account is created, new users are added through the invite flow:
- Admin creates a user at
/admin/users/newwith an email and role. - System generates a one-time invite token with a 7-day expiry.
- Invite is delivered:
- If the
RESEND_API_KEYenv var is set, an invite email is sent automatically via Resend. - If not set, a copyable invite link is shown in the admin UI for the admin to share manually.
- If the
- New user opens
/admin/invite?token=xxxand sets their name and password. - Token is atomically consumed and cannot be reused, including by two concurrent requests.
Only a hash of the invite token is stored in the database. Invite links expire after seven days.
Password recovery
Section titled “Password recovery”When email delivery is configured, users can request a reset from /admin/forgot-password. The response does not reveal whether the address exists. Reset tokens expire after one hour, are stored only as hashes, and are atomically single-use.
Password recovery is enabled by default. Disable it when the deployment has no email adapter or recovery is handled operationally:
export default defineConfig({ admin: { auth: { password: { forgotPassword: false, }, }, }, collections: [users],});Environment variables
Section titled “Environment variables”RESEND_API_KEY= # Optional - enables automatic invite emailsRESEND_FROM_EMAIL= # Optional - sender address (default: Kide CMS <[email protected]>)CMS_TRUSTED_ORIGIN= # Recommended in production - e.g. https://cms.example.comThe same email settings are used for forgot-password recovery.
Set CMS_TRUSTED_ORIGIN to the canonical public origin, including the scheme and port when non-standard. This gives the CSRF check a trusted target origin when a reverse proxy rewrites request host information.
Configuration
Section titled “Configuration”Password login and recovery can be configured through admin.auth. The built-in local provider is the default and normally does not need to be named.
export default defineConfig({ admin: { auth: { password: { enabled: true, forgotPassword: true, }, }, rateLimit: { maxAttempts: 5, windowMs: 15 * 60 * 1000, }, }, collections: [users],});maxAttempts and windowMs control the failed-login budget. The limiter is durable across application instances because its counters are stored in the database.
password also accepts emailVerification (default false), used by auth providers that support it.
Auth collection
Section titled “Auth collection”The built-in users collection is marked with auth: true:
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 } }), },});An auth collection receives stricter default access rules than an ordinary content collection. The password field is automatically hashed on create or update and is omitted from reads, version snapshots, and session user data. Use admin: { hidden: true } to keep it out of the generic edit form.
Middleware
Section titled “Middleware”The middleware in src/cms/middleware/auth.ts (injected by the integration with order: "pre") protects admin pages and CMS API routes, applies security headers, and performs the same-origin check for state-changing browser requests. 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.
Security invariants
Section titled “Security invariants”Changes to authentication must preserve these properties:
- Reusable session, invite, and reset tokens are never stored raw.
- Invite and reset consumption has exactly one winner under concurrency.
- Setup can create only one initial administrator, even under concurrent requests.
- Password hashes are salted, versioned, computationally bounded on verification, and never returned by the CMS API.
- Login failures are durably throttled by both account identifier and client address.
- Unauthenticated login and recovery responses do not reveal whether an email address exists.
- Browser authentication state changes require an explicit same-origin signal; safe HTTP methods do not mutate authentication state.
- Authentication responses are not cached, and session cookies remain HttpOnly, SameSite, and Secure in production.
- Roles are enforced server-side from current user data rather than trusted from client input.
An auth change is not complete until its route behavior, database state, expiry, failure path, and concurrency behavior are tested. Changes that affect persistence or runtime adapters must be verified on both SQLite and D1.