Dual control for restores: maker is not checker
A restore apply is the one path that writes archived data back into your live account. The engine does not roll back what it has written. So you can require a second authorised person’s approval on it, bound to the exact plan, where the approver is not the requester.
This is optional, and it is off until you turn it on. You choose it during setup, and you can change it later in Security Centre. A one-person team can run restores on their own; a team that wants four-eyes on a write-back over production can require it. Turning it on needs at least two Owner identities in the estate, not merely two approver-capable identities, because the engine will not let you arm a gate that a solo estate could never satisfy: an approval must come from someone other than the requester, so arming with fewer would leave nobody able to approve a restore. The engine refuses that rather than letting you discover it during an incident. Once armed, an approval itself can come from any Owner or Approver other than the requester; the two-Owner count is only the arming floor, not a limit on who may approve.
This page covers that mechanic: 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 is always enforced. The dual-control gate is the optional one, and the two are independent: when you have required a second approver, holding the apply role is not enough on its own, and an apply with no matching approval is refused before any data is touched. When you have not, the apply proceeds on your own identity. Every other guard still applies, the mandatory dry run, the plan hash it binds to, the fresh WebAuthn step-up and the full audit trail. 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. 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. The approval’s 24-hour clock does not start here: it started at the dry run this plan came from, so whatever time passed between reading the plan and raising the request has already been spent.
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 engine’s figures 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, 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 is exempt; a Cloudflare Access session must have signed in within the same five minutes, and when it is older the console tells you to sign in to Access again (
requireStepUp,engine/src/admin/router-core.ts). 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 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. 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. It is a presentation hint shown before the round-trip to the engine. 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 |
destinationId | Which archive destination the run is read back from, when set |
d1Tables | The D1 database, the selected table names and createOnly, when set |
cfConfig | The Cloudflare-config account id, the optional zone id, and the resolved list of surfaces the apply may write, when set |
mediaRestore | The account id for an in-account media re-upload, when set |
onExisting | From engine 0.3.6, the choice to keep live KV keys and R2 objects, only when it is "skip" |
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, and so is the per-run master a break-glass restore supplies, which travels on a transport header rather than in the hashed body. Only the non-secret targets are bound. So the plan hash can be shown, copied and recorded without leaking anything sensitive.
The resolved surface list is worth calling out, because it is computed rather than copied. An apply that names no surfaces resolves to the default set, and one that names some resolves to those, so an approver’s hash always covers the concrete write scope rather than a default that could move underneath them.
A changed plan re-arms the gate
Changing any decision field, the run, the destination it is read back from, the target, the selectors, the cap, the single record, the D1 table subset, the Cloudflare-config account, zone or surface list, 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 window that starts at the dry run
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.
An approval lives for 24 hours. The 24 hours are counted from the dry run the plan was read from rather than from the moment the request was raised. The dry run records the instant it was computed, keyed to the plan hash, and the engine stamps the approval’s expiresAt from that instant. So an operator who reads a plan and raises the request three hours later has 21 hours left, not 24. The time an approver then spends deciding comes out of the same 21. Plan against the deadline the dry run published, not against a full day from the request.
Read the clock from the plan, not from the request
The dry-run plan states both instants. plannedAt is when the plan was computed, which is when your window opened, and applyDeadline is the last instant an apply of an approval anchored to that plan can still be writing. Both are on the RestorePlan shape.
Running the dry run a second time for the same plan does not extend a window that is still open. The engine keeps the earliest preview still in play, so a colleague previewing the same plan cannot move the deadline out from under someone already holding the first one. A window that has closed is a different matter: run the dry run again and the plan you get back opens a fresh 24 hours, which is the remedy whenever a preview has aged.
A request with no preceding dry run, which is a direct API caller raising one straight from the plan hash, has no earlier instant to count from, so its 24 hours run from the request. That is not a loophole, because the request route computes the plan server-side at that moment, so the plan and the window still begin together.
Raising the request late does not reopen the window. Between 24 hours and 24 hours and 30 minutes after the dry run the engine still records the request, but the approval it mints is already expired and no apply can spend it. Past that, a request against the same preview is refused outright. The refusal carries a message telling you to run the dry run again and request from the plan it returns.
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.
What the window has to cover
The apply reserves the approval before it writes, and it can only take that reservation while the approval is still live. A reservation taken in the last seconds of the window then keeps writing under a 30-minute lease, so the last instant a restore can still be putting data back is 24 hours and 30 minutes after the dry run. An apply that has not reserved by the time the approval expires cannot start at all.
That later instant, not the 24-hour mark, is what the dry run publishes as applyDeadline. It is the instant the plan’s fidelity warnings are computed against. It is why a plan can name a Workers KV key whose expiration has not passed yet: if the key would lapse before the apply could finish, the plan counts it now rather than letting the apply drop it later with nothing in the approved plan naming it. Applying promptly keeps those expirations, and the receipt records what was actually dropped.
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 recomputed by the engine, 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. It skips a refresh while your keyboard focus is inside the inbox so a decision in progress is not disturbed.
From engine 0.3.6, a request can keep live keys (onExisting: "skip"). The inbox does not show that choice, or how many live keys the plan found. An approver who needs them opens the dry-run plan for the run.
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, which is exempt; a Cloudflare Access session must have signed in within the last five minutes |
| 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. The console renders this 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 .