Authentication and authorization#
Who and what can act on the system, how each identity proves itself, and what each is allowed to do. Verified against
packages/core/src/auth/, apps/core-api/src/server.ts, apps/gateway/src/ and the route contract
scripts/security/routes.expected.json.
1. Identities#
| Identity | Proves itself with | Lives in | Used for |
|---|---|---|---|
| User (administrator, editor, viewer or a custom role) | e-mail + password, optional TOTP second factor; the result is a session cookie | users, sessions |
the dashboard and the editor |
| Visitor (anonymous) | nothing | - | the public read API, public forms, asset delivery of public files |
| Published site | per-website site key (X-Whoobe-Site-Id + X-Whoobe-Site-Key) |
websites.settings (hash, pepper SITE_API_KEY_PEPPER) |
the gateway: resolving bindings, submitting gateway forms. Held by the site's server, never the browser (the SDK offers a same-origin proxy path for that) |
| Service | bearer DAM_SERVICE_TOKEN (shared secret in the environment) |
environment | core-api and the logger calling asset-processor; the only service-to-service credential |
| Preview link | random token in the URL | data_object_preview_tokens |
showing an unpublished data object to a person without an account, until it expires |
| Form-upload ticket | HMAC-signed, 10-minute token from POST /public/uploads/intent (UPLOAD_TOKEN_SECRET) |
in the URL only | sending the bytes of one approved file |
There is no SSO, SAML, OIDC, LDAP or API-token mechanism for users. Automation that needs an identity uses a user account (and therefore a session) or, for content delivery, a site key.
2. Users and passwords#
- Password hashing: argon2id (
argon2package defaults). Minimum length 8 characters at reset and at user creation; there is no complexity, breach-list or rotation rule. - First administrator: created at the first start from
SEED_ADMIN_EMAIL; with noSEED_ADMIN_PASSWORDa random password is generated and printed once in the core-api log (never a default one). Change it at first login. - Login throttling (in memory): after 5 failures from the same client address + e-mail the pair is locked for 15
minutes; 25 failures for one e-mail from any address lock that e-mail for 15 minutes. The client address is
req.ip: setTRUST_PROXY=1behind nginx, otherwise every visitor shares the proxy's address. The table is cleared by a restart and is per process. - Password reset: e-mail with a link (
ADMIN_PUBLIC_URL), the token is stored hashed, lifetime one hour, a reset revokes every session of the user. Forgot-password is rate limited. - Deactivating a user (
is_active=false) ends their access at the next request (the flag is read on every request).
3. Sessions#
| Property | Value |
|---|---|
| Token | 32 random bytes (hex), opaque, no JWT |
| Storage | sessions row whose primary key is the SHA-256 of the token (the token itself is never stored, F-056), with user, expiry, last seen, client IP and user agent |
| Cookie | wcms_session, HttpOnly, SameSite=Lax, Secure when the request is HTTPS or X-Forwarded-Proto: https (override COOKIE_SECURE) |
| Lifetime | 7 days from login; not extended by activity (last_seen_at is only recorded) |
| Revocation | logout; password change or reset (all sessions of the user); deactivation |
| Validation cost | each authenticated request does three queries (session + user, last_seen update, effective permissions); core-api and asset-processor both validate against the same table |
| Concurrency | no limit on parallel sessions, no "log out everywhere" button other than a password change |
| Cleanup | rows expired or revoked more than a week ago are deleted at core-api start and every six hours |
4. Second factor (TOTP)#
- Optional per user, enrolled in the dashboard: TOTP secret + QR code, then a code to confirm; ten one-time recovery codes are shown once and stored hashed.
- The secret is stored AES-256-GCM encrypted (
TOTP_ENCRYPTION_KEY; if unset a warning is logged and secrets stay in clear text; losing the key forces re-enrolment of everyone). - A login with MFA returns a short-lived
mfaTokeninstead of a session; five wrong codes spend the token; ten failures per account lock further attempts for 15 minutes; a code already used is rejected for two minutes (replay); disabling MFA needs the password again (five failures lock for 15 minutes). - MFA cannot be made mandatory for a role or for the installation.
- Break-glass for an administrator who lost both factors: the operator can clear MFA for a user by running the core
reset script with
RESET_USER_EMAIL,RESET_USER_PASSWORDandRESET_USER_CLEAR_MFA(inpackages/core/src/db/scripts/).
5. Authorization#
Model#
Role-based and global to the workspace (one workspace = one organisation; roles are for the functions and divisions of that organisation): user_roles(user, role) → role_permissions(role, permission). A user's
effective permissions are the union over their roles, read from the database on every request (a change applies at
the next request, no re-login). There are no per-website, per-folder or per-object scopes, no deny rules and no
attribute-based rules. Three roles are seeded (admin, editor, viewer); more can be created in the dashboard.
Permissions#
44 keys exist (packages/core/src/auth/permissions.ts): 9 broad keys, 1 gateway key and 34 function keys of the form
<area>:<action> for seven areas (websites, pages, snippets, themes, components, data_objects, assets) and the
actions list, view, edit, create, delete (assets also rename). The model is deliberately about functions (who may
do what), not about divisions or sites (see section 7 of the architecture overview).
Content routes accept a function key or the broad key that always covered them (requireAnyPermission): PATCH /whoobe-one/pages/:id
needs pages:edit or objects:write. So a role built from function keys is limited to those functions, and a role that has the broad
keys keeps working exactly as before. 90 of the 172 permission-guarded routes work this way:
| Area | Routes | Function keys |
|---|---|---|
| pages (and the page templates gallery) | 21 | `pages:list |
| websites (and their menus, themes catalogue, site key, export/import) | 21 | `websites:list |
| data objects (objects, documents, reading classes and workflows) | 18 | `data_objects:list |
| snippets and kits | 18 | `snippets:list |
| themes | 6 | `themes:list |
| components | 6 | `components:list |
The action follows the request: listing a collection is list, reading one item (or its versions, translations, preview) is view,
POST creating is create, PATCH/PUT changing (also thumbnails, lock, translate, render of a snippet) is edit, DELETE is delete.
A role needs view to open an item and list to see the collection; edit alone does not imply either.
The other routes stay on the broad keys, on purpose: they are workspace-level or administrative tools.
| Key | Routes (core-api) |
|---|---|
settings:manage |
31 (mail, AI and Figma settings, website templates, upload limits, PWA generation) |
objects:write |
24 (workspace settings such as autosave and CDN mirror, defining data classes, migration sources and imports, AI generation, Figma import, search reindex, push send) |
users:manage |
12 (users, roles) |
objects:read |
6 (the matching reads: settings, search, generated AI items) |
assets:view / assets:list |
4 / 1 (documentation-site reads) |
objects:publish |
2 (and the publish state of pages and objects, F-035; website import, F-052) |
workflows:manage |
2 |
object_events:manage |
8 (subscriptions that send object events to a service, their delivery log; admin only, refused in the sandbox) |
asset-processor additionally checks assets:upload and assets:delete, and the gateway admin routes gateway:manage.
Building a restricted role. Start from objects:read (so the dashboard can load its lists and settings) and add the function keys:
| Role | Keys |
|---|---|
| Page editor | objects:read, pages:list, pages:view, pages:create, pages:edit, snippets:list, snippets:view, themes:list, themes:view, assets:upload |
| Reviewer (read only) | objects:read |
| Site manager | the above plus websites:*, snippets:*, themes:* |
objects:write is the broad "all content" key: do not give it to a role that must be limited, because it overrides the function keys.
Default roles (seed)#
| Role | Seeded with | What that means in practice |
|---|---|---|
| admin | all 44 keys | everything |
| editor | objects:read, objects:write, assets:upload, assets:delete, workflows:manage and 24 function keys (list/view/edit/create on websites, pages, snippets, data objects, components, assets; list/view on themes) |
reads and writes content and assets; cannot publish (objects:publish), delete objects, manage settings, users or the gateway |
| viewer | objects:read and 14 granular list/view keys |
reads everything, writes nothing |
Before 2026-10-09 the seeded editor and viewer had no objects:* key and got 403 on most routes (F-058); the seed now adds
the missing links at every start, so existing installations are corrected at their next start. The api/third-party-credentials
suite logs in as a user with each seeded role and checks what it can and cannot do. Roles you create yourself need the same flat keys
(objects:read to read, objects:write to change, objects:publish to publish).
Enforcement and proof#
Every route of core-api is declared in scripts/security/routes.expected.json: 214 routes - 172 need a permission, 8
need only a login, 34 are public (a permission key, or one of two keys for the 90 content routes). The authz-matrix suite calls every route as five identities (anonymous, no
permission, read, write, admin) and compares the answer with the contract; the route-drift suite fails when a route
exists in the code and not in the contract (or the reverse). asset-processor and gateway routes are guarded in code
(requireAuth, requirePermission, serviceTokenAuth, site-key middleware) and covered by their own suites
(uploads, gateway) and by the ZAP active scan, but are not in that contract file.
6. The public surface#
Routes under /public/*, /preview/*, /dam-asset/* and /health answer without a session:
- read-only data of published content only (unpublished pages and objects are filtered, also in search - F-033);
- per-address rate limits (
PUBLIC_RATE_LIMIT_PER_MINUTE3000, search 120, query 600, form submit 10 per 15 minutes, upload approval 20 and upload body 30 per minute;0switches a limit off); - CORS: any origin may call them but never with credentials;
- writes exist for two things only: form submission (
/public/forms/:classId/submit, honeypot field, optional notification mail only for a block an author saved - F-053) and upload approval (/public/uploads/intent); - asset delivery: public files by path, private files only with a session (
DAM_ENFORCE_ASSET_VISIBILITY); - preview links: valid until their expiry, read one data object.
7. Browser-facing protections#
| Topic | Implementation |
|---|---|
| CORS | credentialed CORS only for WHOOBE_HOSTNAME (any port or scheme) plus CORS_ALLOWED_HOSTS / CORS_ALLOWED_ORIGINS; localhost also outside production; the null origin never gets credentials (F-011) |
| CSRF | no token. Protection rests on SameSite=Lax cookies, JSON-only state-changing endpoints and the CORS allow-list; there are no state-changing GET routes in the contract. A deployment that serves the admin and a hostile site on the same registrable domain should treat this as weaker than a token |
| Security headers | services: X-Content-Type-Options: nosniff, X-Frame-Options, Referrer-Policy, Cross-Origin-Opener-Policy, X-Powered-By removed; SPAs (nginx): X-Frame-Options: DENY + frame-ancestors 'none' for the dashboard, frame-ancestors from EDITOR_FRAME_ANCESTORS for the editor, nosniff, Referrer-Policy, Permissions-Policy. No full Content-Security-Policy on the SPAs (F-014, postponed) |
| Uploaded files | delivered with Content-Security-Policy: sandbox, attachment for active content types, nosniff |
| Error bodies | generic 5xx in production, SQL text never returned (F-018) |
| Secrets in logs | authorization and cookie headers and common secret keys are redacted by the logger |
8. Gateway credentials#
Upstream credentials (API key, bearer, basic, OAuth2 client-credentials) are entered once, encrypted with
GATEWAY_AUTH_ENCRYPTION_KEY, and used only inside the gateway proxy; the browser, the editor and the published site
see an opaque binding id. Outbound calls go through safeFetch (SSRF guard, threat model).
See API Gateway security.
API clients (applications)#
An application that is not a person calls the gateway at /client/v1 with an API client key (wbk_<8>_<secret>, Authorization: Bearer or X-Whoobe-Key), not a session. The key is stored as an HMAC-SHA-256 with SITE_API_KEY_PEPPER, shown once, and belongs to a client with explicit scopes, limits, optional address list and expiry; no user is created. A browser or an app does not hold a key: it sends the access token of the organisation's identity provider (Keycloak, Entra ID, Cognito...), which the gateway validates (signature against the provider's JWKS, issuer, audience, expiry) and maps to scopes through claim rules. Clients are managed by users with gateway:manage. See the Integrations guide, section 4, and the plan.
9. Known gaps (for a risk assessment)#
No SSO/OIDC; no mandatory MFA; no per-website roles; no password policy beyond length; no session list or remote
sign-out for the user; no CSRF token; no full CSP on the SPAs (F-014);
third-party credentials are in clear text on installations without INTEGRATION_SECRETS_ENCRYPTION_KEY (F-055); login and MFA throttling is in process memory
(per replica, lost at restart).
Last updated