Four-eyes change control for config changes
Four-eyes change control is an opt-in gate that holds a config change until a second authorised person approves it. It is the config-mutation counterpart of the dual control that protects a restore: the person who proposes a change is not the person who approves it, and the approver must hold the same permission the change itself requires.
This page is for an approver and an owner deciding whether to turn it on. It covers which changes the gate holds, what happens to a change when the gate is armed, the deliberately asymmetric off-switch, and the inbox where changes are reviewed. The recurring theme is that the gate adds a second approval without adding a second authority model: a change still runs the same validation and the same capability checks it would without the gate.
Three nearby mechanisms are easy to conflate with change control. Change control is not the dual control over a live-data restore, which binds to a restore plan hash and lives under dual control for restores. It is not the Cloudflare config backup and restore feature, which backs up your Cloudflare account configuration and is covered in the operations section. And it is not the config version history, which records past config versions rather than queuing future ones.
Off by default, and identical to an inline apply when off
The setting that arms the gate, requireConfigApproval, defaults to off. While it is off, a config mutation applies inline exactly as it would if the feature did not exist. The code path is byte-identical to a plain inline apply: there is no second-approver requirement, no pending record, and no change to the response a route returns. Apart from the auto-armed cases later on this page, nothing about change control affects an account until an owner arms it. One narrow exception, covered just below, is a retention-pruning edit that auto-gates on its own condition.
One config mutation auto-gates on its own condition, never on the toggle: a downpipe edit that arms scheduled retention pruning, or widens an already-armed policy, queues for approval when someone other than the editor holds restore.approve (autoGateRetentionArm, engine/src/sched/scheduler-do-change-control.ts). With no other qualified approver the edit still applies at once, as narrowing edits and the bare admin token always do. When an arming or widening edit applies at once, the engine writes a retention-armed-alone audit row and raises a warning notification.
That property is what makes the gate safe to leave off and safe to turn on. Turning it on does not retroactively change anything, and turning it off returns to the inline path. The only difference the gate makes is in the window where it is armed.
The same requireConfigApproval toggle does more than gate config mutations. Arming it also arms second-owner dual control over a closed set of high-blast-radius owner operations that are not config-history mutations:
- Adding, editing, repointing or removing a destination, and repointing the default destination
- Opening or repointing the SIEM push destination or the OTLP metrics push destination
- Creating, deleting, enabling or disabling an identity-provider connection, or rolling its certificate
- Setting the discovery token or the browsed accounts
- Attaching a source
- Applying an engine update, or keeping one after its canary, when the signed release is marked migration or breaking (a release with no class counts as migration), or when every update needs approval (see the table below)
- Minting a support credential
- Retiring the break-glass token
Those operations carry their own dual-control approval, run through a parallel approval store rather than the config-change inbox, but they are armed by this one switch. So turning the gate on is a single decision that brings four-eyes to both config changes and these owner operations at once.
What the gate covers, and what it does not
When the gate is on, it covers a closed set of config-mutating routes uniformly. The set is exactly the config mutations that record into config history, and it is a fixed union in the code, so a crafted request cannot reach the apply path through some unlisted kind.
The gate covers:
- Saving or deleting a downpipe
- Setting or deleting a role
- Setting or deleting a custom role
- Setting or deleting a group mapping
- A notify channel, rule or webhook change
- Accepting or un-accepting a posture risk
- A tracked expiry item change
- A coverage inventory update
- A Cloudflare-config downpipe’s capture-mode change (auto or manual)
Several privileged actions are not covered as config mutations, and they fall into two groups that are easy to conflate. Some are dual-control owner operations armed by the same requireConfigApproval toggle, so when the gate is on they do get a second owner, just through the parallel owner-action store rather than the config-change inbox. Others are plain owner-only operations that this toggle never dual-controls at all, gated only by requiring an owner. Do not assume either group routes through the config-change inbox.
| Not covered as a config mutation | How it is gated instead |
|---|---|
| Cloudflare config restore | Restore dual control, a separate gate armed by its own owner toggle (requireRestoreApproval), which is off by default |
| Require a second owner for every engine update | A separate owner toggle (requireAllUpdateApproval), which is off by default. When it is on, every engine update consults the second-owner gate, whatever the release risk class |
| Key ceremony | Plain owner-only, on the keys.ceremony capability |
| Destinations (set, repoint or remove) | Dual-control owner-action gated, auto-armed as soon as a second owner exists, independent of the requireConfigApproval toggle |
| Identity-provider connections (create, delete, enable, disable or certificate change) | Dual-control owner-action gated, auto-armed as soon as a second owner exists, independent of the toggle |
| SIEM push destination (set or repoint) | Dual-control owner-action gated, auto-armed as soon as a second owner exists, independent of the toggle |
| OTLP metrics push destination (set or repoint) | Dual-control owner-action gated, auto-armed as soon as a second owner exists, independent of the toggle |
| Turning the restore-approval or the update-approval requirement off | Dual-control owner-action gated, auto-armed as soon as a second owner exists, independent of the toggle; the bare break-glass token still turns either off at once |
| Repointing the default destination, the discovery token and browsed accounts, attaching a source, support-credential mint, break-glass-token retire | Dual-control owner-action gated, armed only by the requireConfigApproval toggle |
| Engine updates (apply, or keep after the canary) | Dual-control owner-action gated, armed by the requireConfigApproval toggle. The gate is consulted only for a migration or breaking release (a release with no class counts as migration), unless requireAllUpdateApproval is on |
| Canary configuration | Plain owner-only; deliberately not dual-controlled |
| Licence activation | Plain owner-only on keys.ceremony; deliberately not dual-controlled |
Six of these owner operations do not wait for the toggle. Adding, repointing or removing a destination (the dest-set, dest-put and dest-remove actions); creating, deleting, enabling or disabling an identity-provider connection, or rolling its certificate; opening or repointing the SIEM push destination; opening or repointing the OTLP metrics push destination; and turning off the restore-approval or the update-approval requirement, all auto-arm second-owner dual control as soon as a second owner exists, even when requireConfigApproval is off. The first four stop a lone compromised owner redirecting backups, destroying proven copies, changing who signs in, or redirecting audit or telemetry egress. The two off-switches stop a lone owner dropping a second-person control over restores or updates with no second owner and no alert. This auto-arm does not extend to repointing which destination is the default seal target, to the discovery token or browsed accounts, to attaching a source, to engine updates, to a support-credential mint or to break-glass-token retirement: those stay governed by the toggle. With only one owner none of these auto-arm, since there would be no second owner to approve, and the bare-token break-glass owner is exempt so the first owner can still wire the first identity provider and can always turn either off-switch off.
Two kinds auto-arm independent of the toggle for a different reason: setting a role to Owner, and removing the Owner role from a current Owner (a demotion to another role or a custom role, or a delete), both auto-arm as soon as a second owner exists, exactly like the destination and identity-provider auto-arm above. A lone owner cannot mint a second owner, or remove one, without a second owner approving. The Owner being removed may approve their own removal; an access-admin may not, even holding roles.write. Revoking every sign-in factor of a current Owner is refused outright while two or more Owners exist, rather than queued: remove the Owner role first, then revoke.
The gate covers config mutations, in other words, not the whole surface of privileged operations. The plain owner-only operations carry only an owner check. The dual-control owner operations share this toggle but live in their own approval store rather than this inbox.
What happens on propose when the gate is on
When the gate is armed, a covered config route does not apply the change. It validates the change fully, and if the change is sound it queues it as a pending change request. The validation is not a lightweight pre-check: it runs the real mutation.
The real mutation runs under a checkpoint
The engine takes a checkpoint over its whole storage keyspace, and captures the durable alarm separately because a downpipe change can re-arm it. It then runs the actual validated mutation method, the same one a gate-off apply would run, including its no-escalation and last-owner guards and its per-method capability re-check.
The change is rolled back
The engine rolls the checkpoint back unconditionally, restoring every changed key, deleting any key the mutation added, and resetting the alarm to its prior state. A mere proposal therefore leaves no side effect at all, not even a re-armed alarm.
A bad or unauthorised request is rejected now
Because the real method ran, a request that fails validation, exceeds the proposer’s authority, or would break the last-owner guard throws at propose time. The route returns a 400 then and there, or a 403 for an authority failure, with nothing queued. The proposer learns immediately, rather than after a wasted approval.
A sound request is queued as a pending change
A clean run yields the configuration the change would have produced, which the engine diffs against the current configuration to render a plain-English change list. It stores a pending change record carrying that diff, the proposer, the time and an integrity binding, and the route answers with a 202 and the change id.
The diff the engine renders is the same plain-English diff the config version history uses, reading only named configuration metadata, so the diff the console shows you is redaction-safe: a webhook reads as its host only, and a routing key reads as present rather than as its value. The stored record is more than that diff, though. Alongside the id, the kind of change, the diff, the proposer’s identity and a timestamp, the engine keeps the verbatim validated mutation body so it can replay the change on approval, and for a webhook or channel change that body can include a URL token or a routing key.
So do not read a change record as carrying no secret. The diff is safe to show; the stored record holds the change’s real parameters. The pending-change list and the approve and reject responses redact them: a URL reads as its host only, and a routing key or API key is left out. When a change becomes applied, rejected or superseded, the engine also removes those fields from the stored record. A change that stays pending keeps them in storage until someone decides it.
The owner-action inbox applies the same rule to a pending push destination. The listing shows the endpoint of a SIEM push or OTLP push request, and the S3 target endpoint, as scheme, host and any port only. The request carries endpointRedacted: true when anything was cut. The request then carries endpointRedacted: true (redactPushEndpoints, engine/src/admin/owner-action.ts).
The approval rules
Approving a pending change applies it, by replaying the same validated mutation through the single apply path. Three rules gate an approval, and all of them are enforced server-side inside one atomic read-modify-write so the decision and the apply cannot diverge. Before any of them runs, approving needs a fresh step-up sign-in; rejecting does not, because only the direction that applies a change is gated.
Approval rules:
- The first rule is maker is not checker. The approver must differ from the proposer on the stable-subject axis, which is the same immutable identity the role table keys on, exactly as the restore dual control compares subjects, so a self-approval is refused even across an email change. The engine also compares the display email as a second check, so it refuses a self-approval on either axis.
- The second rule is that the approver must hold the same write capability the original mutation requires, re-resolved live from the engine’s own tables rather than trusted from anything forwarded. Approving a role change needs
roles.write, approving a notify change needsnotify.config, and so on. The mapping is the same per-route capability the change would have needed inline, so the gate adds no new authority. One exception: a role change that grants the Owner role, or that removes the Owner role from a current Owner, can only be approved by an Owner, never by an access-admin merely holdingroles.write, so a lone compromised owner cannot mint a second owner, or remove one, through the approval path either. - The third rule is no stale or superseded apply. The pending change binds a content hash over its kind, its parameters, the proposer’s identity and the config-history head it was computed against. At approve time the engine recomputes that hash and re-reads the current head. If the parameters or the proposer identity were tampered, the hash no longer matches and the change is refused. If the head has moved since the proposal, the diff the approver reviewed is stale, so the change is marked superseded and refused, and it must be raised again.
Two further checks apply. The approval must arrive at least five seconds after the proposal. At approve time, the engine also re-checks that the proposer still holds the change’s write capability.
The approver approves exactly what they reviewed
The stale check is the defence against a change being approved against a configuration it was never reviewed against. An approver approves the precise change they saw, against the precise base it was computed against, or nothing applies. A concurrent config change between proposal and approval supersedes the pending change rather than silently applying it against a moved base.
A rejection has a lighter rule. Only a still-pending change can be rejected, but a rejection does not require maker is not checker, because the proposer is allowed to withdraw their own change.
The states a pending change moves through
Once queued, a change carries one of four lifecycle states, so a reader always knows where it stands.
| State | What it means |
|---|---|
| Pending | Queued and awaiting a second approver. It is the only state that can be approved or rejected. |
| Applied | A distinct approver approved it and the validated replay committed. This is terminal, and a change applies exactly once. |
| Rejected | It was declined by a second party, or withdrawn by the proposer, and nothing was applied. |
| Superseded | The configuration moved between propose and approve, so the diff the approver reviewed was stale. The change is refused and must be raised again against the current configuration. |
The engine lists pending changes only, so the console inbox shows each change with one status badge. The label of that badge is “Awaiting approval”. A change leaves the inbox when a second approver applies or rejects it, or when the proposer withdraws it. A change that the engine marks superseded leaves the inbox too. The config history screen shows the configuration versions that the engine records after it applies a change. A superseded change is a stale change to raise again against the current configuration, not a timeout: there is no time-based expiry for a pending change.
The asymmetric off-switch
Arming and disarming the gate are deliberately not symmetric, because they carry very different risk. Arming a control, or re-stating it, is always safe, so it applies immediately. Disarming is the one dangerous direction: a single compromised owner who could flip the gate off, run every covered change inline, and flip it back on would have bypassed the gate entirely.
So the off-switch is gated against itself, with one escape.
| Direction | Who | What happens |
|---|---|---|
| Arm the gate | An owner | Applies immediately. An attributable owner can arm it only when a second owner exists. |
| Disarm, attributable owner | A passkey, Access or identity-provider owner | Routed through a second-owner approval; it only takes effect when a distinct second owner approves. |
| Disarm, break-glass | The bare-token owner | Applies immediately, because the break-glass owner is the lockout escape and cannot itself propose or approve owner actions. This path exists only while the break-glass token stays in place. |
Arming is always immediate, and a break-glass token that is still in place disarms immediately, so a lone owner cannot lock the account out of its own off-switch. An owner can retire the break-glass token. After that, every disarm by an attributable owner waits for a second owner. The engine refuses whichever of three conditions would complete the combination of the gate on, the token retired and an Owner grant that expires: arming the gate, retiring the token, or granting a time-boxed Owner role (wouldStrandDualControl, engine/src/admin/owner-floor.ts).
Every disarm leaves an audit trail and raises an alert:
- An immediate disarm, by the break-glass token, writes one
config-policy-changeaudit event. It names the policyconfig-approvaland the new valuefalse, and carries the actor. - A queued disarm writes an
owner-action-proposeevent when an owner queues it. When the second owner approves, the engine writesowner-action-approveandowner-action-executeevents and the sameconfig-policy-changeevent. - The engine raises a
dual-control-disabledalert at warning severity on the notify channels you configure. The engine never batches this alert into a digest. It names the actor’s email when one is known and the path: the break-glass admin token or an approved second-owner disarm. - A queued disarm raises no alert until the second owner approves it.
The engine sends the alert after the disarm succeeds. If alert delivery fails, the disarm still stands and the audit event exists.
The toggle is owner-only. It is gated on the owner-exclusive keys.ceremony capability, which an access-admin does not hold, and the engine additionally re-resolves the caller’s role and requires owner. An access-admin can manage roles and the access policy, but cannot arm or disarm the four-eyes gate.
The restore-approval requirement and the update-approval requirement share this same asymmetric off switch, as two sibling owner-action kinds rather than this config gate itself: arming either is always immediate, an attributable Owner’s disarm queues for a second Owner once two or more exist, the bare break-glass token always disarms either at once, and every disarm of either raises the same real-time alert, naming the policy it turned off.
The change-request inbox
The console surfaces pending changes in a change-request inbox, which mirrors the restore approvals inbox in look and behaviour. It is a polled list of cards. Each card shows the engine-rendered plain-English diff, who proposed the change, when it was proposed, and Approve or Reject actions.
The inbox mirrors the engine’s rules so a button is never a dead end. Approve is hidden on a caller’s own proposal, with a note that they proposed it and are awaiting another approver. For a caller whose capability set does not hold the change’s write capability, Approve and Reject are both hidden, and a note names the capability the change needs. The engine remains the authority and re-checks both rules; the console mirror only saves the approver a futile click.
Reading the inbox is allowed for every built-in role, on the downpipe.read capability, the same floor that lets a caller view the configuration at all. The approve and reject actions are gated on the change’s own write capability.
When the gate is armed, a queued config action shows a queued-for-approval state with a link to the inbox. It does not show a false success. You learn that your change is waiting, not that it applied.
The bare token cannot take part in four-eyes
Four-eyes needs two attributable, stable identities to compare. The bare-token break-glass owner has no email and no subject, so it can neither propose a config change nor approve one. It refuses both. Its one related power is the immediate disarm above, the lockout escape. For why a subjectless caller cannot be a maker or a checker, see the identity and access overview.
Reference: the propose and approve outcomes
These are the outcomes a covered config route and the approve route return when the gate is armed.
| Status | Outcome | When | What to do |
|---|---|---|---|
| 200 | Applied inline | The gate is off | Nothing; the change applied as normal. |
| 202 | Queued | The gate is on and the change validated | Note the change id; a second approver actions it in the inbox. |
| 400 | Rejected at propose | The change failed validation, or would break the last-owner guard | Fix the request and propose again; nothing was queued. |
| 403 | Forbidden at propose | The change exceeded the proposer’s authority | Ask someone who holds that authority to propose it; nothing was queued. |
| 400 | Cannot self-approve | The approver is the proposer on the subject or email axis | A different authorised person must approve. |
| 400 | Missing capability | The approver does not hold the change’s write capability | An approver who holds that capability must action it. |
| 400 | Integrity check failed | The stored record’s hash did not recompute | The record cannot be applied; raise the change again. |
| 400 | Superseded | The configuration moved since the change was proposed | Raise the change again against the current configuration. |
| 403 | Proposer authority lapsed | The proposer no longer holds the change’s write capability | A second approval does not help. Someone who holds that capability must propose the change again. |
| 401 | Step-up required | The approver has not freshly re-authenticated with a passkey or Access | Re-authenticate, then approve again. |
| 429 | Too soon | The approval arrived within five seconds of the proposal | Wait and approve again; the response carries a Retry-After header. |
The exact request and response bodies for the policy toggle, the pending-change list, and the approve and reject routes are catalogued in the admin endpoints reference.
Require Change Number
Require Change Number is a separate, optional policy that sits alongside four-eyes approval on the same approval-policy view. It is owner opt-in and off by default. When it is on, every change-controlled action prompts for a change reference before it proceeds, and the reference is recorded in the audit log against that change.
The set it covers is not the config-mutation set above, and reading it as though it were is the mistake this section exists to prevent. Change control and four-eyes are two axes over two different populations, and an action can sit on either, both or neither.
What Require Change Number covers:
| Covered | Which actions, and why they are here |
|---|---|
| Every dual-control owner operation except four | Setting, repointing, editing or removing an archive destination, and repointing the default. Opening, replacing or repointing the SIEM push destination and the OTLP metrics push destination. Creating, deleting, enabling, disabling or rolling the certificate on an identity-provider connection. Setting the account-browsing discovery token and choosing the browsed accounts. Minting a support credential. Retiring the break-glass bearer token. |
| Clearing either push destination | Neither clear is a dual-control owner operation, because closing an egress is the safe direction and queuing it for a second owner would hold a suspect path open. Each is change-controlled at its own engine route instead, so a clear under this policy still carries a reference. |
| Applying a restore | The router asks the engine to enforce the policy and record the reference before it writes a byte. |
What it does not cover:
- The four exempt owner operations: applying an engine update, settling one as keep or rollback, attaching a source, and disarming the four-eyes gate. An update is exempt because its bytes are verified against the signed channel before anything goes live and its rollback is the safe recovery direction; disarming four-eyes is exempt because a change-number requirement on the off switch would be one more thing between an account and its own escape hatch.
- Every config mutation in the covered-by-four-eyes list above. Saving a downpipe, setting a role, composing a custom role, mapping a group, changing a notify rule or channel, accepting a posture risk, tracking an expiry item, updating a coverage inventory and setting a Cloudflare-config capture mode all record into config history and can be queued for a second approver, but none of them prompts for a change number. If your CAB expects a reference against a role grant, the audit log’s own record of who granted what and when is the evidence available, not a change reference.
The prompt asks for one of two things. A normal change carries a change number, your CAB or ITIL ticket reference of up to sixty-four characters. A change with no number to hand is raised as an Emergency Change instead: a loud, deliberately visible mode that requires a justification of up to five hundred characters and takes an optional retrospective number, so an out-of-hours fix is still recorded and stands out on the trail.
The console refuses both bounds at the field. At the engine, the two bounds behave differently past their limit, and that is deliberate. A change number longer than sixty-four characters is refused, by the console at the field and by the engine at the request, so nothing runs until it is corrected. A change number is a reference to a record in your own change-management system, so a shortened one would still look like a reference and match nothing, which is the one job it has. A justification longer than five hundred characters that reaches the engine is accepted and its tail is dropped, because it is prose: losing the end costs detail rather than identity, and the full account belongs in the change record itself. Control characters and leading or trailing whitespace are stripped before either bound is measured, so a reference pasted with a stray line break is not refused for the sake of it.
The reference is a process and compliance control, not a security control. No gate reads it and it confers no authority: it rides on the request exactly like the operator’s source IP, request-scoped provenance the engine records but never trusts. It is operator-attested free text, bounded and stripped of control characters, in the same redaction-safe class as a restore reason, never a secret. So the change number tells your auditors which CAB change a configuration edit belonged to; it does not decide whether the edit is allowed. Whether an edit needs a second approver is the four-eyes gate above, a different control.
When the policy is on, the security centre shows it as enabled, the posture score’s emergency-change-review check watches for emergency changes, and a change-requests report lists the references recorded. Turning the policy off is itself owner-only, on the same keys.ceremony capability, though, unlike the four-eyes toggle above, this one is never itself dual-control gated.
Where this fits
- Dual control for restores is the live-data counterpart, gating a restore apply rather than a config change, and it is a separate mechanism.
- Roles and capabilities defines the write capabilities an approver must hold, and confirms that
keys.ceremonythat gates the toggle is owner-reserved. - Identity and access overview places change control alongside the rest of the section and explains the stable-subject axis the maker-is-not-checker rule compares.
- Audit log records the propose, the approve, any supersede, and every disarm, and is tamper-evident.
Last updated .