Skip to content
downpipes docs

Authentication and authorisation reference for the engine API

This page documents two things a developer needs to call the engine admin API correctly: how a request proves who it is, and how each route decides whether that caller may proceed. It is the API-auth view. For the meaning of each role in product terms (who you would assign to whom, and why), the canonical home is roles and capabilities; this page does not re-derive that and instead gives the wire-level rules and the exact matrix the engine enforces.

Authentication and authorisation are separate steps. First the engine establishes a verified identity (the authorise gate). Then, for a mutating route, it runs a capability check against that identity’s role. A 401 means the first step failed; a 403 means the first step passed but the second did not. Keeping them on distinct status codes lets the console show a sign-in prompt for one and an “your role cannot do this” message for the other.

Authenticating: three methods in strict precedence

A caller authenticates by one of three methods, checked in a fixed order (authorise, engine/src/admin/auth.ts). The order is a precedence from higher assurance to lower, and the rule that makes it safe is that a present-but-invalid higher method is rejected outright and never falls through to a weaker one.

Order Method How it is presented Attribution
1 Cloudflare Access JWT The cf-access-jwt-assertion header, verified server-side against the configured team domain and audience A verified email and a stable subject
2 Session cookie An ambient cookie minted by the engine’s own sign-in, carrying which method issued it (passkey, native OIDC, or native SAML) A verified email and a stable subject
3 ADMIN_TOKEN bearer An Authorization: Bearer <token> header, compared in constant time None: the email-less, all-or-nothing break-glass

The anti-downgrade rule is the core property. If a Cloudflare Access assertion is present but does not verify, the request is denied; it does not then try the session cookie or the token. If a session cookie is present but does not verify (a bad MAC, an expired or tampered token), the request is denied; it does not fall through to the token path. Only the genuine absence of a higher credential lets control reach a lower one. Absent any usable credential, the gate fails closed with a 401.

There are two extra fail-closed checks on the higher methods. A validly-signed Access assertion that carries no verified email is rejected (a Cloudflare Access service token is signed for the audience but carries no email), and so is one that carries no stable subject. The engine authorises on the immutable subject, so a caller with no subject has no stable identity to authorise and cannot be folded into the break-glass owner downstream (authorise, engine/src/admin/auth.ts).

The Cloudflare Access JWT

When Cloudflare Access is configured (CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD are set) and a request carries the assertion header, the engine verifies the JWT signature against the team’s published keys, checks the audience and expiry, and extracts the email, the stable subject, the expiry, and any signed identity-provider groups. The key fetch is pinned to an https host under *.cloudflareaccess.com and refuses to follow a redirect, so neither a tampered team-domain value nor a 3xx can steer it off-surface (fetchCertsCached, engine/src/admin/auth.ts).

A session cookie is the engine’s own front door, independent of Cloudflare Access. A successful passkey login, a native OIDC authorisation-code flow, or a validated SAML assertion mints a signed, short-lived cookie. The signing key lives only in the scheduler Durable Object, so the engine verifies the cookie by a Durable Object round-trip rather than holding the key in the router (PasskeySessionVerifier, engine/src/admin/auth.ts). The verified identity comes back as the email, the stable subject taken verbatim from the signed payload, the issuing method, and the live identity-provider group snapshot. The role is re-resolved from the subject on every request; the cookie itself never carries a role.

The three cookie-borne methods (passkey, OIDC, SAML) are exactly the ones that need a CSRF guard, because an ambient cookie is something a foreign page could ride on a state-changing request. See the Origin requirement below. The full set of sign-in flows is documented in sign-in flows.

The ADMIN_TOKEN bearer (break-glass)

The ADMIN_TOKEN bearer is the lowest-assurance method and is meant for one job: a one-time bootstrap of the first Owner before a passkey or an Access seat exists. It is not attributable to a person, so it resolves to the all-or-nothing owner break-glass and carries no email and no subject. The comparison is constant-time over a SHA-256 digest of the presented and configured tokens, so neither the contents nor the length leaks through timing (tokenEqual, engine/src/admin/auth.ts).

Two controls let you remove this fallback once it is no longer needed.

Control Effect Persistence
ADMIN_TOKEN_DISABLED env flag The token path is refused before it is even considered; only a verified Access JWT or a valid session is accepted Set in the engine’s environment (a redeploy)
In-app retirement An Owner calls POST /admin/policy/retire-break-glass-token; the engine stops honouring the token immediately A durable flag in the Durable Object, so no redeploy

The two combine: the effective “token fallback off” predicate is the env flag OR the in-app retire latch. The retire route is owner-gated and is refused unless a way back in already exists (recovery codes for an Owner, or a second Owner), so retiring the token cannot lock everyone out (engine/src/admin/router.ts). The retire latch lives in the Durable Object and the resolver that reads it fails closed on its own error, so a leaked token can never get a fail-open bypass by knocking the Durable Object over (BreakGlassRetiredResolver, engine/src/admin/auth.ts).

The engine cannot delete its own ADMIN_TOKEN from the Worker environment, because it holds no standing Cloudflare token by design. In-app retirement is how an Owner disables the bearer without a redeploy: a retired token is refused exactly as ADMIN_TOKEN_DISABLED refuses it. Once your Owner’s passkey works, retire the break-glass token.

The stable-subject model

Authorisation keys on a stable, immutable subject, never on the email. An Access caller’s subject is the issuer plus the Access user id; a passkey caller’s subject is derived from the bound email in a distinct namespace; a native OIDC or SAML caller’s subject folds in the connection id, the issuer or entity id, and the immutable subject or persistent NameID (passkeySubject, oidcSubject, samlSubject, engine/src/admin/identity.ts). The email is retained for display and for the audit trail only. Because the role table is keyed on the subject, a recycled or reassigned email address can never inherit a departed member’s role. The bare token is the one method with no subject, which is exactly why it is the all-or-nothing break-glass and not a normal seat.

A cookie-borne session (passkey, OIDC, or SAML) is an ambient credential, so every mutating request (any non-GET, non-OPTIONS) authenticated by a cookie must also carry an Origin header that exactly matches CONSOLE_ORIGIN. This is a CSRF defence layered on top of the cookie’s own SameSite=Strict. A mismatch is refused with a 403 { "error": "csrf origin check failed" } before the request can touch any state (engine/src/admin/router.ts). The Access and token methods present an explicit header that a cross-site page cannot set on a credentialed request, so they are not ambient-credential vectors and are exempt. A GET is a read and is exempt. The check fails closed on a missing Origin, and a real browser fetch from the console always sends one, so a legitimate console write is never blocked.

Authorising: a capability check, not a role rank

Once a caller is authenticated and their role is resolved, each mutating route calls gate(caller, capability). This is a single lookup: does the caller’s role hold the named capability (gate over callerCan over ROLE_CAPABILITIES, engine/src/admin/router.ts and engine/src/admin/identity.ts). It is deliberately not a role-rank comparison. Two of the roles hold a precise subset of powers rather than a prefix of owner, and a single rank cannot express that, so authority is decided by an explicit capability map and nothing else.

A failed check returns a 403 whose JSON body names the exact capability and the caller’s role:

{
  "error": "forbidden",
  "required": "restore.apply",
  "have": "operator"
}
Field Meaning
error Always the literal forbidden, so the console discriminates it from a 401 sign-in failure
required The exact capability the route gates on
have The caller’s resolved role

Most reads are not gated this way. A GET (listing downpipes, reading history, reading the role table) is permitted for any authenticated role, because reading is not a write; the console escapes any sensitive values on render. The capability gate mostly applies to the mutating routes, which are POST requests, with one deliberate exception: GET /admin/idp/presets and GET /admin/idp/connections are gated on keys.ceremony like the rest of the native identity-provider management surface, so a group-conferred access-admin cannot even read the connection list, let alone add a hostile one. This is defence in depth against self-escalation, not a general pattern; every other GET route is open to any authenticated role.

Defence in depth at the Durable Object

The router gate is not the only enforcement point. Almost every route forwards inward to the scheduler Durable Object, and the router forwards the resolved caller in an internal header, x-downpipe-caller. That header is set by the router on a Durable Object fetch it builds from scratch and is never copied from an inbound request, and the router overwrites any inbound value, so a client cannot forge it (CALLER_HEADER, engine/src/admin/identity.ts). The Durable Object then re-resolves the role from its own tables and re-enforces the authority at the commit point, so a write is checked both at the router and again where it actually lands.

The role-by-capability matrix

There are six roles and twenty-one capabilities. The table below is the complete ROLE_CAPABILITIES map, reproduced from engine/src/admin/identity-rbac.ts (re-exported from identity.ts for callers that import the role model alongside authentication). A tick means the role holds the capability; a blank means it does not. This is the single source of authorisation: the engine reads exactly this map, and the console mirrors it so a client affordance is shown only when the server would allow it.

Capability viewer operator restore-operator approver access-admin owner
downpipe.read Y Y Y Y Y Y
downpipe.write Y Y Y
downpipe.delete Y Y Y
run.trigger Y Y Y
drill.run Y Y Y Y
restore.dryrun Y Y Y Y Y Y
restore.verify Y Y Y Y Y Y
restore.request Y Y Y Y
restore.apply Y Y Y
restore.approve Y Y Y
roles.read Y Y Y Y Y Y
roles.write Y Y
access.policy Y Y
keys.ceremony Y
audit.read Y Y Y Y Y Y
notify.config Y Y Y
expiry.config Y Y Y
scheduledtest.config Y Y Y
reports.read Y Y Y Y Y Y
posture.read Y Y Y Y Y Y
posture.riskaccept Y

A few things to read out of the matrix rather than infer from a name.

viewer is the least-privilege resting role, and a new member defaults to it. It reads everything and can run the side-effect-free recoverability proofs (restore.dryrun previews a plan, restore.verify runs a blind restore test or a keyless attestation), but it writes nothing. restore.verify is granted from the viewer floor up precisely because proving an archive restores writes no bytes back and surfaces no plaintext, so it is as safe as a read.

operator adds the data and configuration operations: creating, editing, deleting and triggering downpipes, configuring notifications, retention expiry and scheduled tests, and raising a restore request. It cannot apply or approve a restore, manage people, or touch keys.

approver is operator plus the restore apply and approve capabilities. It is the cumulative top of the operate-and-recover line below owner.

restore-operator is a narrow recovery-only role, deliberately not on the cumulative ladder. It holds the reads, the drill capability, and the full restore lifecycle (request, apply, approve), but it cannot create, edit, delete or trigger downpipes, configure notifications or retention, manage people, or touch keys. Its powers are a specific subset, not a prefix of owner.

access-admin is the other narrow role: people only. It reads everything the others read, and it manages roles and the access policy (roles.write, access.policy). It cannot write data, apply a restore, or touch keys.

owner holds every capability. Two of them are owner-exclusive and cannot be carved into any custom role: keys.ceremony (the highest-consequence cryptographic action, including the key install and the native identity-provider connection management) and posture.riskaccept (the owner’s deliberate sign-off that a security check may legitimately fail). The owner is the single named holder of these two powers (OWNER_RESERVED_CAPABILITIES, engine/src/admin/identity-rbac.ts).

Do not describe an owner-only route as merely “admin”. A route that gates on keys.ceremony or posture.riskaccept is owner-exclusive: an access-admin granted by an identity-provider group cannot reach it, which is what stops a group mapping from quietly conferring the ability to wire a hostile identity provider or to accept away a failing posture check.

Custom roles, in one line

A custom role is an account-defined, named bundle of capabilities that sits alongside the six built-ins and is reached only by an explicit per-email grant or an identity-provider group mapping referencing its name. A custom role can never contain an owner-reserved capability and can never grant a capability its creator does not already hold (validateCustomRole, engine/src/admin/identity-rbac.ts). A custom-role caller is gated by exactly its bundled capability set, through the same callerCan check. See custom roles.

Where this fits

Last updated .