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 with an optional or mandatory TOTP second factor, or the organisation's OpenID Connect provider; the result is a session cookie | users, sessions, user_identities |
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) |
| API client (an application outside Whoobe) | a key (Authorization: Bearer wbk_... or X-Whoobe-Key), or a token of your identity provider validated by its signature |
api_clients |
the client surface /client/v1: published data, drafts of the classes it may write, Integrations routes, automations; only the scopes it was given (API clients) |
| 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 |
Staff can sign in with one OpenID Connect provider (section 4b); SAML and LDAP are not built (a broker such as Keycloak covers them). An application or a flow that needs to read or write data uses an API client; the administration API (users, roles, settings, websites, pages) has no machine credential, so a script that administers Whoobe uses a user account (and therefore a session).
2. Users and passwords#
- Password hashing: argon2id (
argon2package defaults). A password policy applies where a password is set (user creation, reset), never to a login and never to existing passwords: minimum length (default 8, up to 128, Security > Sign-in Requirements), optionally at least three of lowercase, uppercase, digit and symbol, and never the e-mail address or its local part.GET /auth/password-policy(public, no secret) says what it is. There is no 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; eight one-time recovery codes are shown once and stored hashed. An administrator can make it mandatory for chosen roles (below).
- 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). - Mandatory for roles (Security > Sign-in Requirements,
GET/PUT /security-settings, permissionsettings:manage, audited). Empty by default: an upgrade changes nothing. A user holding one of the chosen roles who has no second factor signs in with the password and gets{ mfaEnrolmentRequired, enrolToken }instead of a session; the dashboard shows the QR code,POST /auth/mfa/enrol/verifyproves the code, enables the factor, returns the recovery codes once and only then opens the session. The token is in memory, lasts ten minutes, is spent by five wrong codes, and the per-account code lock and the used-code memory apply. A session with reduced rights was deliberately not built. - A user of a required role cannot turn MFA off (
409). Sessions that are already open when the setting is switched on keep working until they end or the user signs in again. API clients (keys, JWT) are not users and are not affected. - If a required user loses the phone and the recovery codes, the break-glass below clears the factor and the next sign-in is a new enrolment.
- 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/).
4b. Single sign-on (OpenID Connect)#
Security > SSO, GET / PUT /oidc-settings (settings:manage, audited). One provider per installation. How to set it up for
Keycloak, Entra, Okta and Google: single sign-on. The design and its decisions: docs/plans/system/oidc-login-plan.md.
- Flow: authorization code with PKCE (S256).
GET /auth/oidc/loginkeepsstate,nonceand the verifier in memory for ten minutes and sets awcms_oidccookie with a random binder whose hash is kept with them;GET /auth/oidc/callbackrefuses a state that is unknown, expired, already used or whose binder is not in this browser's cookie (login CSRF), exchanges the code (client secret by HTTP Basic when there is one), and verifies the ID token: signature from the provider's JWKS (algorithm allow-list: nonone, no HS256),issexactly the configured issuer,audthe client id,exp,azp,nonce. Only the ID token is kept for the length of the request; the access and refresh tokens are discarded. - Where the browser goes is always the configured dashboard address; a failure goes to
/?sso_error=<code>with a short code and the cause in the log. Nothing in the request chooses the destination. - Who the person is:
(issuer, sub)inuser_identities. A first sign-in with an unknown pair links to the local account of the same e-mail only if the provider saysemail_verified(switchlinkByEmail), else creates an account ifautoProvisionis on, else is refused. An unverified e-mail never links or creates. - Roles: a claim (
groups,roles,realm_access.roles) and a mapping value -> role. At every sign-in the roles in the mapping follow the claim (granted or taken away); roles given by hand are left alone; a new person matching nothing gets the default roles; a person who would have no role is refused (and not created). - Deprovisioning: a person deactivated at the provider cannot authenticate there, so cannot start a new session; a user deactivated here is refused at the callback. A session already open is not cut (there is no back-channel logout): the same 7 days as for a local account.
- Second factor: the mandatory-2FA setting applies to the password form. A person who signs in through the provider uses the provider's factor; Whoobe does not ask again.
- Local accounts stay.
localLoginisalloradmins(the password form works only for users who can manage users); there is no "none" on purpose, so a provider outage cannot lock everyone out. - Outbound: discovery, JWKS and token requests go through the outbound guard (
safeFetch); addresses must be https, or http outside production or withOIDC_ALLOW_HTTP=true. - Audit:
setting.oidc_update(client secret: "changed") and the eventsuser.sso_provision,user.sso_link,user.sso_roleswith the actor "Single sign-on". - Not built: SAML, LDAP, several providers, single logout / back-channel logout, SCIM, refresh of the provider session.
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) |
audit:read |
1 (read the audit log; admin only; separate from users:manage so that an auditor cannot change users) |
ai_usage:read |
2 (the AI token usage of every person and the monthly totals; admin only; each person reads their own with a login) |
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: 270 routes - 217 need a permission, 10
need only a login, 43 are public (a permission key, or one of two keys for the 106 content routes; counted 2026-10-27). 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)#
SSO with a single OpenID Connect provider (no SAML or LDAP, no single logout); MFA is mandatory only for the roles an administrator chooses (no installation-wide switch, no per-user exception); no per-website roles; the password policy is a minimum length, an optional three-of-four character classes rule and "not the e-mail" (no breached-password list); 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