Dual control for restores: maker is not checker
A restore apply is the one path that writes archived data back into your live account, and the engine does not roll back what it has written. So beyond the role gate that decides who may apply at all, a restore apply takes a second authorised person’s approval, bound to the exact plan, where the approver is not the requester.
This page is the canonical home for that mechanic. It covers how a restore moves through request, approve and reject; why the approver must differ from the requester on a stable identity rather than on an email; how the approval is tied to a single plan hash; and how the approvals inbox lets an approver review and decide.
The role gate and the dual-control gate are independent and both mandatory. Holding the apply role is never enough on its own: an apply with no matching approval is refused before any data is touched. For who holds which capability, see roles and capabilities.
The lifecycle
The engine holds the approval record in the scheduler Durable Object, which is the single storage authority. The record moves through a closed set of states, and the request, the approve, the apply lookup and the verify all read the same shared logic so the binding and the state can never be computed two different ways.
Request
A requester with the request capability raises a request bound to the plan, with a free-text reason for the trail. The engine recomputes the plan hash server-side from the submitted request, so the binding the approval keys on is the engine’s, not a value the client could choose. A requester with no stable identity cannot raise a request.
Review in the inbox
The request lands in the approvals inbox. The blast-radius figures shown there are recomputed by the engine, not taken from the request body, so an approver sees the true impact of the plan rather than whatever the requester claimed.
Approve or reject
A different authorised person with the approve capability approves that exact plan hash, or rejects it. A self-approval is refused at approve time. A reject does not require a distinct identity, because a requester may withdraw their own request.
Approving from any cookie-borne session, passkey, OIDC, SAML or a session established by recovery-code sign-in, now also requires a fresh WebAuthn step-up. The engine is satisfied only when the session was authenticated within the last five minutes or a fresh single-use passkey step-up token is presented; it fails closed if that check is unavailable and returns 401 otherwise. The console runs the passkey assertion and retries automatically. A bare break-glass token session and a Cloudflare Access session are exempt. Raising a request and rejecting one are not covered by step-up, because neither writes to live data.
Apply
An apply with the apply capability is allowed only once a usable approval exists for this exact plan hash, with the approver distinct from the requester. The engine re-checks the distinct-identity rule at apply time, so the apply gate cannot be satisfied by approving your own request.
The apply now carries its own fresh WebAuthn step-up as well, on the same terms as the approve. It is asked for after the capability check and before the engine reserves the approval, so an apply from a stale session is refused before any approval is spent and before a byte is written. The same request also carries the read-only dry-run, which is deliberately not gated: the prompt appears on the confirmed apply only.
Consume
The approval is consumed only after a successful apply. A failed apply leaves the approval usable, so a retry does not need a fresh round of dual control. A second apply against a consumed approval is refused.
Maker is not checker, on stable identity
The distinct-identity rule compares the stable subject of each person, the issuer and subject from their sign-in, not their email address. Each record stores the requester’s subject as the maker and, once approved, the approver’s subject as the checker. The two subjects must differ.
Emails are recorded alongside the subjects for display and for the audit trail, but they are never the comparison axis. A recycled email whose subject differs is a legitimate distinct checker, and the same person signing in with a different email case is still recognised as the maker and refused as a self-approver.
The rule is enforced in two places. At approve time the engine refuses a self-approval outright. At apply time the engine re-checks that the recorded checker subject differs from the requester subject, so a record that somehow carried the same subject on both axes would still be rejected.
The console pre-flight is a hint, not the control
The console shows an early “you cannot approve your own request” cue by comparing emails. That is a presentation hint to keep the screen honest before a round-trip. The security control is the engine, which compares the stable subject at approve time and again at apply time.
The approval binds to a plan hash
An approval is keyed to a plan hash: a SHA-384 over a canonical object of the restore request’s decision-relevant fields only, written as sha384: followed by the hex digest. Two requests that would restore the same data the same way hash equal, and any change to a decision field yields a different hash.
The hash is recomputable from the apply request on its own, so the apply route can look the approval up without re-running the dry-run and without a race. The dry-run plan’s blast-radius facts are stored on the record for the approver to read, not folded into the key, so the key stays evaluable from the request alone.
These are the fields that go into the hash.
| Field | What it binds |
|---|---|
runId |
The run the plan restores |
target |
The target binding name, namespace id or bucket name, never a value |
include |
The include selectors |
exclude |
The exclude selectors |
maxRecords |
The record cap on the apply |
recordName |
The exact record name for a single-record restore, when set |
cfConfig |
The Cloudflare-config account id and optional zone id, when set |
mediaRestore |
The account id for an in-account media re-upload, when set |
What the hash leaves out is as load-bearing as what it includes. It folds in no plaintext, no key, and no token. The Cloudflare-config and media re-upload tokens in particular are excluded by design: only the non-secret targets are bound, the account and optional zone for a Cloudflare-config apply, and the account for a media re-upload. So the plan hash can be shown, copied and recorded without leaking anything sensitive.
A changed plan re-arms the gate
Changing any decision field, the run, the target, the selectors, the cap, the single record, the Cloudflare-config account or the media re-upload account, produces a different plan hash. A prior approval was keyed to the old hash, so the changed plan simply has no matching approval and must be requested and approved again. In the console a fresh dry-run clears the prior confirmation and says so.
Single-use and a fixed expiry
An approval authorises one apply. The engine consumes it only after a successful apply, in one atomic read-modify-write, so a consumed approval can never authorise a second apply. A failed apply does not consume the approval, so the same approval stays usable for a retry without re-requesting.
Approvals expire after a fixed 24 hours. Expiry is lazy: a lapsed record reads as expired without the engine rewriting storage, and an apply against it is refused, which forces a fresh request and approval. A record that is already rejected or consumed is not overwritten by expiry. A short window means an approval cannot be banked indefinitely against some future apply.
The bare break-glass token cannot raise or approve
The one-time bootstrap token is the owner break-glass path, and it carries no stable subject because it is not an attributable identity. Dual control needs two attributable, stable identities, so a caller on the bare token can neither raise a request nor approve one: the engine refuses both. Dual control is therefore a property of identified people, not of a shared secret.
The approvals inbox
The inbox is where an approver does the work. It lists requests awaiting a second authorised identity, newest first, with each record’s effective status, so a lapsed request reads as expired without a storage write.
| Who | What they see |
|---|---|
| An approver or owner | Every request in the inbox |
| A requester | Their own requests, matched on their stable subject |
For each request the inbox shows the run, the blast-radius cues recomputed by the engine, the requester and the reason, and offers approve or reject. The figures are the engine’s recomputed truth, so an approver is never shown benign cues over a plan that would in fact restore a large or stale run. The console polls the list so a newly raised request appears without a manual reload, and it skips a refresh while your keyboard focus is inside the inbox so a decision in progress is not disturbed.
Role floors
Each step has its own capability floor, independent of the dual-control binding above it.
| Action | Needs |
|---|---|
| Raise a request | The request capability |
| Approve or reject | The approve capability, plus a fresh WebAuthn step-up when approving from any cookie-borne session (passkey, OIDC, SAML or a session established by recovery-code sign-in), except a bare break-glass token session or a Cloudflare Access session, which are exempt |
| Apply | The apply capability, a matching usable approval, and a fresh WebAuthn step-up on the same terms as the approve, with the same two exempt sign-in methods |
A reject uses the same capability as an approve. The role gate is checked first at the router, and the engine re-checks the capability inside the Durable Object as defence in depth, so a request that reached the storage layer without authority fails closed. The full role-to-capability table lives in roles and capabilities.
How the engine ties the pieces together
The plan hash is sha384: plus the hex of a SHA-384 over the canonical JSON of the decision-relevant fields, the same primitive the codebase uses for fingerprints elsewhere. Optional fields are included only when they carry a value, so an absent field and an explicit empty value hash identically and the key stays stable.
When a request is raised, the router recomputes both the plan hash and the blast-radius cues server-side. The cues come from running the same read-only plan the apply would run, with the write flag forced off, so they reflect the engine’s own view of the run. If a direct API caller sends cues that disagree with the recomputed ones, the engine ignores the client values, keeps its own, and logs the divergence coarsely as a misuse signal.
At apply time the router asks the Durable Object, read-only, whether a usable approval exists for the plan hash before touching live data. A usable approval is one whose effective status is approved, with a checker subject recorded, where the checker subject differs from the requester subject. If none exists the apply is refused with an explicit “restore not approved” outcome carrying the plan hash, which the console renders as an awaiting-approval state rather than a mystery failure. On a successful apply the router calls a separate consume step that flips the record to consumed in one atomic write.
The apply is the audited event. It records both identities on both axes, the applier as the actor and the approver carried from the usable approval, along with the reason. The audit log carries operator identity by design.
Related
- The restore flow walks the end-to-end path from picking a run to the receipt.
- Roles and capabilities defines the request, approve and apply capabilities and which role holds each.
The same maker-is-not-checker idea applies to high-impact configuration and owner operations under change control.
Last updated .