Docs / Secure
Auth and access
How people sign in to EdgeCMS and what decides which admin routes they can call. Sign-in uses Better Auth with sessions stored in D1. Public delivery routes need no sign-in; see Public API Guide.
Code: apps/api/src/auth/, apps/api/src/invites/, apps/api/src/tenants/tenant.middleware.ts, apps/api/src/app.ts. PRD items: HS-01 (sign-up policy), HS-04 (tenant states), HS-07 (rate limits), HS-11 (MFA).
Settings
Set these as Worker vars or secrets (wrangler secret put NAME --env <env>). The deployment runbook lists the full set.
| Variable | Default | Purpose |
|---|---|---|
BETTER_AUTH_SECRET | none | Signs sessions and CSRF tokens. Must be at least 32 characters, or auth fails to start. |
BASE_URL | request origin | Canonical deployment URL. Also used in invite links. |
SIGNUP_POLICY | open | open, invite-only or disabled. Any other value stops the Worker at bootstrap. |
AUTH_EMAIL_SENDER | unset | Only console is accepted: writes verification and invite emails to the Worker log. Development only. |
SUPER_ADMIN_EMAILS | unset | Comma-separated super-admin addresses. Each must be verified. |
SUPER_ADMIN_REQUIRE_MFA | false | Super-admins need a second-factor session on admin routes. |
SUPER_ADMIN_DEV_MODE | unset | true lets every signed-in user through the super-admin-only routes (/api/admin/tenants, /api/admin/invites), but only while SUPER_ADMIN_EMAILS is empty. Never set in production. |
ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET, ENTRA_TENANT_ID | unset | Microsoft Entra sign-in. Tenant ID defaults to common. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | unset | Google sign-in. |
JWT_HS256_SECRET | unset | Signs and verifies short-lived access tokens. |
JWT_ISSUER, JWT_AUDIENCE | unset | Checked on access tokens when set. |
JWT_REQUIRED_FOR_ADMIN | false | true rejects admin requests without a bearer access token (401 JWT_REQUIRED). |
Hosts that compose the app in code can set the sign-up policy, email sender and super-admin MFA through host.auth or configureAuthPolicies(); code values win over env vars. See Hosting Seams.
Recommended for new deployments: SIGNUP_POLICY=disabled and SUPER_ADMIN_REQUIRE_MFA=true.
Sign-in methods
Better Auth is mounted at /api/auth. The routes the admin uses:
POST /api/auth/sign-up/email { name, email, password, inviteToken? }
POST /api/auth/sign-in/email { email, password }
POST /api/auth/sign-in/social { provider: "microsoft" | "google", callbackURL? }
GET /api/me current user, with role "superadmin" when applicable- Email and password is always on. Passwords are 8 to 128 characters.
- Microsoft Entra is on only when both
ENTRA_CLIENT_IDandENTRA_CLIENT_SECRETare set. - Google is on only when both
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETare set. - Sessions last 7 days and refresh once a day.
- A new user's global
roleisviewer. Sign-up and update-user input cannot set it.
Password reset is not wired up. The admin has a "Forgot password" page, but the API configures no reset email, so reset requests do not send anything.
SSO / SAML — next (honest stub)
Social sign-in (Entra / Google) covers many enterprise IdP “social” paths when those env vars are set. Packaged SAML 2.0 / enterprise SSO metadata, SCIM, and forced-SSO tenant policy are not shipped. Say “SSO next” on pilot calls; do not demo a SAML ACS. Track demand against Entra/Google first.
Sign-up policy (HS-01)
| Policy | Who can create an account |
|---|---|
open | Anyone. |
invite-only | Only someone with an unused, unexpired invite for that exact email. |
disabled | Nobody. |
The policy is checked on every account creation path, including the first Microsoft or Google sign-in.
First-account bootstrap. While the user table is empty, one account can always be created, whatever the policy. Create the operator account right after deploying. GET /api/bootstrap/status returns { "needsOnboarding": true, "userCount": 0 } until then. After signing in, POST /api/bootstrap/provision-tenant gives a user their first tenant, with the user as owner (returns the existing one if they already have a tenant).
Rejected sign-ups return 403 with one of these codes: SIGNUP_DISABLED, INVITE_REQUIRED, INVITE_INVALID, SUPER_ADMIN_EMAIL_UNVERIFIED.
Invites
Super-admins manage invites. Invites matter only under SIGNUP_POLICY=invite-only.
GET /api/admin/invites list recent invites (no tokens)
POST /api/admin/invites create an invite
DELETE /api/admin/invites/:inviteId revoke an unused invite{
"email": "editor@example.com",
"tenantSlug": "acme",
"tenantRole": "admin",
"expiresInHours": 72
}tenantRoleisowner,admin,editor,memberorviewer(defaultmemberwhentenantSlugis set without a role). WithtenantSlug, the new user joins that tenant when the account is created.expiresInHoursdefaults to 72 and can be 1 to 720.- The response (
201) holds the token once, plussignUpPath(/sign-up?invite=<token>) andemailed(whether an email sender sent the link). Only a SHA-256 hash of the token is stored. - Each invite works once and only for its email.
- The sign-up request carries the token as
inviteTokenin the body or thex-edgecms-invite-tokenheader. The admin sign-up page reads it from?invite=.
Email verification
EdgeCMS ships no email provider. Verification turns on when an email sender is configured:
import { createApp } from 'edgecms-api/app'
export const app = createApp({
host: {
auth: {
emailSender: {
send: async ({ to, subject, text }) => {
await myMailer.send({ to, subject, text }) // your provider
},
},
},
},
})With a sender:
- Email/password accounts must verify before they can sign in. A verification email goes out on sign-up and again on each sign-in attempt while unverified.
- Clicking the link verifies the address but does not sign the user in, so the password and any second factor are still required.
- Invite emails are sent automatically when an invite is created.
For local development, AUTH_EMAIL_SENDER=console writes these emails to the Worker log. The links in the log are live credentials.
Super-admins
A user is a super-admin when any of these holds:
- their global
roleissuperadmin; - their email is in
SUPER_ADMIN_EMAILSand verified; SUPER_ADMIN_DEV_MODE=trueandSUPER_ADMIN_EMAILSis empty (super-admin-only routes and the MFA requirement only; the general admin route guard does not use dev mode).
An unverified listed address grants nothing. A listed address can only be registered when verification is enforced (an email sender is set) or the identity provider verified it; otherwise sign-up fails with SUPER_ADMIN_EMAIL_UNVERIFIED. The runbook explains how to fix an operator account created before this check.
Super-admins can call every admin route, including /api/admin/tenants, /api/admin/invites and tenant routes in any state. When SUPER_ADMIN_EMAILS is empty and dev mode is off, super-admin routes return 403 with Super-admin access is not configured. Set SUPER_ADMIN_EMAILS.
No API route changes a user's global role; set user.role in D1 if you need superadmin, admin or editor globally.
MFA (HS-11)
Users enrol under Settings → Profile in the admin. The Better Auth routes behind it:
POST /api/auth/two-factor/enable { password } -> totpURI, backupCodes
POST /api/auth/two-factor/verify-totp { code }
POST /api/auth/two-factor/verify-backup-code { code }
POST /api/auth/two-factor/disable { password }After enrolment, password sign-in returns twoFactorRedirect and the admin asks for a TOTP code or a recovery code. Each recovery code works once.
A session counts as MFA-verified only when it was created by verify-totp or verify-backup-code (or replaces the user's own MFA-verified session). Disabling two-factor clears it. Microsoft and Google sign-ins never count as MFA-verified.
Two requirements can be switched on:
- Super-admins:
SUPER_ADMIN_REQUIRE_MFA=true. Enforced on every authorization-checked admin route and on/api/mcp. - Per tenant: a super-admin sends
PUT /api/admin/tenants/:tenantSlugwith{ "requireMfa": true }. Enforced for everyone, super-admins included, on that tenant's/api/tenants/:tenantSlug/api/admin/*and/api/tenants/:tenantSlug/api/mcp/*routes. Public reads are not affected.
Blocked requests get:
{ "success": false, "error": { "code": "MFA_REQUIRED", "message": "Multi-factor authentication is required. ..." } }with status 403. Access tokens are not minted for such sessions, and changing a tenant's requireMfa makes its existing tokens stale.
Roles
There are two layers.
Global role (user.role): viewer (default), editor, admin, or superadmin. editor and admin can call global /api/admin/* routes; admin can also list /api/admin/users.
Tenant role (membership in tenant_users): owner, admin, editor, member or viewer. On /api/tenants/:tenantSlug/api/admin/*:
| Tenant role | Read admin routes | Mutate admin routes | List users, read audit |
|---|---|---|---|
owner, admin | yes | yes | yes |
editor | yes | yes | no |
member | yes | no | no |
viewer | no | no | no |
Other cases: no session returns 401 UNAUTHORIZED; a role without the ability returns 403 FORBIDDEN.
Memberships are managed by super-admins:
GET /api/admin/tenants/:tenantSlug/users
POST /api/admin/tenants/:tenantSlug/users { userId, role: "owner" | "admin" | "editor" | "member" | "viewer" }
PUT /api/admin/tenants/:tenantSlug/users/:userId { role }
DELETE /api/admin/tenants/:tenantSlug/users/:userIdAdmin Users → Invite calls POST /api/admin/invites with the same role defaults (super-admin session). Existing-user add still uses POST …/tenants/:slug/users with a real userId.
Timed pilot path: Security walkthrough (10 min).
Custom roles
Each tenant gets built-in viewer, editor, admin and owner role rows with permission lists. Tenant routes for roles:
GET /api/tenants/:tenantSlug/api/admin/roles
GET /api/tenants/:tenantSlug/api/admin/roles/:roleId
GET /api/tenants/:tenantSlug/api/admin/roles/:roleId/permissions
POST /api/tenants/:tenantSlug/api/admin/roles { name, description?, permissions?: [{ subject, action, conditions? }] }
PUT /api/tenants/:tenantSlug/api/admin/roles/:roleId
DELETE /api/tenants/:tenantSlug/api/admin/roles/:roleIdCurrent limits, stated plainly:
- The request guard checks coarse subjects only (
TenantAdminApi,GlobalAdminApi, plus the users and audit reads). Stored permissions such asentry:*orasset:*are listed by the API but do not restrict individual routes. A stored grant takes effect only when its subject isTenantAdminApiorGlobalAdminApi. - Membership and invite APIs assign the coarse roles above (
owner…viewer). They do not assign a custom role row id to a user. - Role routes need the same ability as other tenant admin mutations, which includes
editor.
Attribute-based access (ABAC)
Default-deny attribute policy sits on top of RBAC. It runs only when the request declares attributes via headers or query:
| Attribute | Header | Query |
|---|---|---|
| brand | x-edgecms-brand | brand |
| locale | x-edgecms-locale | locale |
| channel | x-edgecms-channel | channel |
| env | x-edgecms-env | env |
There is no separate brand table yet — for partner demos send the tenant slug as x-edgecms-brand. Locale catalog, delivery channels, and linked environments already exist in D1.
Permission conditions JSON scopes a grant, for example { "locale": ["en", "fr"], "env": "preview" }. null / omitted conditions are unrestricted. * matches any value for that key. With attributes present, at least one effective permission grant must match or the guard returns 403 FORBIDDEN with Attribute policy denied and appends abac.denied to the tenant audit chain. Super-admins skip ABAC. Requests without attribute headers stay RBAC-only.
{
"name": "fr-preview-editor",
"permissions": [
{
"subject": "TenantAdminApi",
"action": "mutate",
"conditions": { "locale": ["fr"], "env": "preview", "brand": "acme" }
}
]
}Public-plane denial receipt (honest scope)
Admin ABAC does not automatically gate anonymous public reads. Hosts opt in with createApp({ host: { publicAbac: { enabled: true, grants } } }) — delivery grants on the Worker, not user permission rows.
When enabled, list / entry / singleton public content routes evaluate locale + channel from the delivery request (plus optional brand / env headers). On deny they return 403 with:
{
"error": "Attribute policy denied",
"code": "ABAC_DENIED",
"receipt": {
"plane": "public",
"outcome": "denied",
"reason": "no_matching_grant",
"attributes": { "locale": "fr", "channel": "published" },
"policyScope": "host.publicAbac",
"evaluatedAt": "2026-10-28T12:00:00.000Z"
}
}Headers: x-edgecms-policy-decision: denied, x-edgecms-policy-receipt: <json>, Cache-Control: no-store. Audit still appends abac.denied.
Out of this slice: public assets, GraphQL, per-entry ACL, day-one pilot gate for every anonymous read (default remains off).
Access tokens
Signed-in users can trade their session for a short-lived bearer token. The admin does this to skip D1 lookups on each request.
GET /api/auth/access-token?tenant=acme{ "success": true, "data": { "token": "<jwt>", "expiresAt": "2026-10-22T08:10:00.000Z" } }- HS256 JWT signed with
JWT_HS256_SECRET(500 CONFIG_ERRORif unset), valid for 10 minutes. - Carries the user, global role, tenant id, slug and role, MFA and email-verified flags.
403 FORBIDDENif the user is not a member oftenant;403 MFA_REQUIREDif a required second factor is missing.- Send it as
Authorization: Bearer <token>. Role or permission changes make older tokens stale: requests then get401 TOKEN_STALEand the client should fetch a new token.
EdgeCMS has no long-lived personal or machine API tokens for admin routes. EDGECMS_API_KEY and MCP_API_KEY are shared keys accepted only by the MCP endpoint (see MCP Server).
Tenant states (HS-04)
Set with PUT /api/admin/tenants/:tenantSlug and { "status": "..." } (super-admin).
| State | Admin, MCP, AI, sync | Public reads and GraphQL | Super-admins |
|---|---|---|---|
active | allowed by role | allowed | allowed |
suspended | blocked, 403 TENANT_SUSPENDED | allowed | allowed |
pending | blocked, 403 TENANT_UNAVAILABLE | blocked | allowed |
disabled (and legacy inactive) | blocked, 403 TENANT_UNAVAILABLE | blocked | allowed |
Preview-token requests are blocked while suspended. See Hosting Seams for how hosts pick the initial state.
CSRF
Cookie-based admin writes need a CSRF token:
GET /api/csrfsets acsrf_tokencookie (SameSite=Lax, 24 hours) and returns{ "success": true, "data": { "csrfToken": "..." } }.- Send the same value in the
x-csrf-tokenheader.
Required on every POST, PUT, PATCH and DELETE to a path that starts with /api/admin/, contains /admin/, starts with /api/me/, or mints a preview token (…/public/preview-token, not verify) when the request carries a Better Auth session cookie. Pure Authorization: Bearer API clients skip CSRF (not browser cookie CSRF). If both a session cookie and a bearer token are present, CSRF is enforced. A missing or wrong token returns 403 CSRF_REQUIRED. Better Auth routes under /api/auth use Better Auth's own origin checks instead.
POST …/public/preview-token also requires authentication and a mutate role (tenant editor+ on tenant routes; global admin mutate on /api/public/preview-token). Verify stays public.
Non-/api/auth requests with a body must use Content-Type: application/json (asset upload also accepts multipart/form-data); others get 415 UNSUPPORTED_MEDIA_TYPE. JSON bodies over 1 MiB get 413 PAYLOAD_TOO_LARGE.
Rate limits (HS-07)
Every /api/* request except /api, /api/health, /api/health/live and /api/docs is counted. Buckets are per tenant, per caller (user id, else cf-connecting-ip), per method and path, and are shared across isolates through the CACHE KV namespace.
| Category | Matches | Limit per 60 s |
|---|---|---|
auth | /api/auth/*, /api/csrf | 30 |
ai_command | .../admin/ai/command, .../admin/ai/workflow | 20 |
admin_mutation | non-GET on admin paths | 60 |
public_read | GET/HEAD on public paths | 300 |
default | everything else | 120 |
Over the limit:
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests",
"details": { "dimension": "admin_mutation", "kind": "rate", "scope": "caller", "limit": 60, "retryAfterSeconds": 12 }
}
}with status 429 and a Retry-After header. Hosts can replace these numbers per tenant and add a tenant-wide budget (scope: "tenant"); see Hosting Seams. These are local test values (apps/api/src/tests/auth/rate-limit.middleware.test.ts), not production measurements.
Checks
bun test --isolate apps/api/src/tests/auth/auth-policies.test.ts apps/api/src/tests/auth/mfa-enforcement.test.ts \
apps/api/src/tests/auth/csrf.middleware.test.ts apps/api/src/tests/auth/rate-limit.middleware.test.ts \
apps/api/src/tests/auth/authorization.test.ts apps/api/src/tests/auth/secrets.test.ts \
apps/api/src/tests/auth/super-admin.test.ts apps/api/src/tests/invitesASVS L2 / API Security control IDs: owasp-asvs-l2.md.