Contents · Secure

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.

VariableDefaultPurpose
BETTER_AUTH_SECRETnoneSigns sessions and CSRF tokens. Must be at least 32 characters, or auth fails to start.
BASE_URLrequest originCanonical deployment URL. Also used in invite links.
SIGNUP_POLICYopenopen, invite-only or disabled. Any other value stops the Worker at bootstrap.
AUTH_EMAIL_SENDERunsetOnly console is accepted: writes verification and invite emails to the Worker log. Development only.
SUPER_ADMIN_EMAILSunsetComma-separated super-admin addresses. Each must be verified.
SUPER_ADMIN_REQUIRE_MFAfalseSuper-admins need a second-factor session on admin routes.
SUPER_ADMIN_DEV_MODEunsettrue 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_IDunsetMicrosoft Entra sign-in. Tenant ID defaults to common.
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETunsetGoogle sign-in.
JWT_HS256_SECRETunsetSigns and verifies short-lived access tokens.
JWT_ISSUER, JWT_AUDIENCEunsetChecked on access tokens when set.
JWT_REQUIRED_FOR_ADMINfalsetrue 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_ID and ENTRA_CLIENT_SECRET are set.
  • Google is on only when both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set.
  • Sessions last 7 days and refresh once a day.
  • A new user's global role is viewer. 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)

PolicyWho can create an account
openAnyone.
invite-onlyOnly someone with an unused, unexpired invite for that exact email.
disabledNobody.

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
json
{
  "email": "editor@example.com",
  "tenantSlug": "acme",
  "tenantRole": "admin",
  "expiresInHours": 72
}
  • tenantRole is owner, admin, editor, member or viewer (default member when tenantSlug is set without a role). With tenantSlug, the new user joins that tenant when the account is created.
  • expiresInHours defaults to 72 and can be 1 to 720.
  • The response (201) holds the token once, plus signUpPath (/sign-up?invite=<token>) and emailed (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 inviteToken in the body or the x-edgecms-invite-token header. 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:

ts
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 role is superadmin;
  • their email is in SUPER_ADMIN_EMAILS and verified;
  • SUPER_ADMIN_DEV_MODE=true and SUPER_ADMIN_EMAILS is 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/:tenantSlug with { "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:

json
{ "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 roleRead admin routesMutate admin routesList users, read audit
owner, adminyesyesyes
editoryesyesno
memberyesnono
viewernonono

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/:userId

Admin 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/:roleId

Current limits, stated plainly:

  • The request guard checks coarse subjects only (TenantAdminApi, GlobalAdminApi, plus the users and audit reads). Stored permissions such as entry:* or asset:* are listed by the API but do not restrict individual routes. A stored grant takes effect only when its subject is TenantAdminApi or GlobalAdminApi.
  • 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:

AttributeHeaderQuery
brandx-edgecms-brandbrand
localex-edgecms-localelocale
channelx-edgecms-channelchannel
envx-edgecms-envenv

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.

json
{
  "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:

json
{
  "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
json
{ "success": true, "data": { "token": "<jwt>", "expiresAt": "2026-10-22T08:10:00.000Z" } }
  • HS256 JWT signed with JWT_HS256_SECRET (500 CONFIG_ERROR if unset), valid for 10 minutes.
  • Carries the user, global role, tenant id, slug and role, MFA and email-verified flags.
  • 403 FORBIDDEN if the user is not a member of tenant; 403 MFA_REQUIRED if a required second factor is missing.
  • Send it as Authorization: Bearer <token>. Role or permission changes make older tokens stale: requests then get 401 TOKEN_STALE and 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).

StateAdmin, MCP, AI, syncPublic reads and GraphQLSuper-admins
activeallowed by roleallowedallowed
suspendedblocked, 403 TENANT_SUSPENDEDallowedallowed
pendingblocked, 403 TENANT_UNAVAILABLEblockedallowed
disabled (and legacy inactive)blocked, 403 TENANT_UNAVAILABLEblockedallowed

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:

  1. GET /api/csrf sets a csrf_token cookie (SameSite=Lax, 24 hours) and returns { "success": true, "data": { "csrfToken": "..." } }.
  2. Send the same value in the x-csrf-token header.

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.

CategoryMatchesLimit per 60 s
auth/api/auth/*, /api/csrf30
ai_command.../admin/ai/command, .../admin/ai/workflow20
admin_mutationnon-GET on admin paths60
public_readGET/HEAD on public paths300
defaulteverything else120

Over the limit:

json
{
  "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

bash
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/invites

ASVS L2 / API Security control IDs: owasp-asvs-l2.md.

Edit docs/public/auth-and-access.md on GitHub