Approval Step-Up
An approval is credential-equivalent — an approved instance action is a job that mints an admin kubeconfig. So no approval counts on a signed-in session alone: the approver re-proves presence for THAT action, and the server — never the page — checks the proof before it acts. Policy
approval-step-up(register).
GitHub calls it sudo mode. Here it is a step-up receipt: a short-lived, single-use record minted only after a fresh, strong authentication, and bound to exactly what is being approved. Every approval path consumes one, and refuses — by parking the approval, never by silently proceeding — when there is none.
The ladder — always prefer a passkey
Policy approval-step-up fixes the order: always prefer a passkey, use a second factor only where
no passkey is available.
| The approver signed in with… | Step-up method (method on the receipt) |
Why |
|---|---|---|
| a Microsoft (Entra ID) account | entra — an OIDC round trip with prompt=login and the declared Conditional Access authentication context, whose policy requires the Phishing-resistant MFA authentication strength |
The tenant already governs these accounts and their passkeys (FIDO2 / Windows Hello / Authenticator passkeys); one prompt, the tenant's own policy, nothing duplicated |
| any other account (Google, LinkedIn, Apple, GitHub) | passkey — the portal's own WebAuthn assertion |
These providers cannot step up (measured below), so the portal does it itself |
| any other account, on a device that can do no passkey and with no passkey enrolled | totp — the portal's own RFC 6238 code |
The last rung, offered only when the passkey rung is impossible |
An Entra account is never sent through the portal's own passkey as well — one prompt, not two.
The receipt records the method, so a later policy can require entra/passkey only for the
highest-risk actions without changing the receipt.
Why the portal runs its own passkey for non-Microsoft accounts (measured)
The discovery documents, read 2026-10-09:
- Google (
accounts.google.com/.well-known/openid-configuration) supportspromptandmax_agere-authentication and returnsauth_time, but advertises noacr_values_supportedand noamrclaim (claims_supported= aud, email, email_verified, exp, family_name, given_name, iat, iss, name, picture, sub). A Google re-login therefore proves a fresh sign-in, never how — a password re-entry and a passkey are indistinguishable to us. - LinkedIn (
www.linkedin.com/oauth/.well-known/openid-configuration) advertises noprompt, nomax_age, noauth_time, noacr, noamr— there is no step-up to ask it for. - Entra ID advertises
auth_timeandacr, and issuesacrswhen the requested authentication context's Conditional Access policy was satisfied.
So only Entra can attest phishing-resistant, just now. For everyone else the portal holds the authenticator itself.
The receipt
One shape for every method and every consumer.
| Field | Meaning |
|---|---|
Id |
random, 128 bits, hex |
UserId |
the mesh user id of the approver (AccessContext.ObjectId) — never the email |
Method |
entra · passkey · totp — an open vocabulary (StepUpMethod) |
Targets |
the actions it covers: { ActionPath, Binding } each — the node path being approved and the hash of what was shown (InstanceAction plan digest, OperationRequest script hash, Activity content hash). A bulk approval is ONE step-up covering N targets |
IssuedAt · ExpiresAt |
ExpiresAt = IssuedAt + ReceiptLifetime (default 5 min) |
AuthenticatedAt |
when the user actually authenticated (auth_time for Entra, the assertion time for a passkey, the code time for TOTP) |
Evidence |
what was verified, for the audit line (acrs=c1 amr=fido tid=…, the credential id, …) — never a secret |
Seal |
HMAC-SHA256 over every field above, keyed from the instance master key (HKDF, purpose MeshWeaver.StepUp.Receipt.v1) |
Where it lives: Auth/_StepUp/{id}, written as System by the step-up endpoint only. The node
type's access rule admits System alone for every operation; the seal makes even a write that
bypassed it worthless. The seal's material is canonical — every field length-prefixed, instants as
UTC ticks, the targets counted — so no two receipts share bytes.
Only the platform mints. The public IStepUpService is check-and-consume only; minting lives on
the internal StepUpService, visible to the portal host that carries the step-up endpoints and to
nothing compiled in the mesh — otherwise any code able to resolve it could stamp itself a valid
receipt and skip the authentication it stands for.
Single use: consuming target T of receipt R CREATES Auth/_StepUpUse/{R}-{key(T)} as
System. Creation is atomic at the owning hub, so a second consumer's create fails — a replay is
refused by the store itself, not by a field someone could reset.
How a receipt reaches the approval it covers
The step-up endpoint stamps the receipt id onto each target node, as the approver, under the
content property stepUpReceipts — a map { userId → receiptId }, so several signers of one
activity each carry their own. The stamp is an ordinary GetMeshNodeStream(path).Update(...); it
changes the node, so the owning watcher re-evaluates the approval with the receipt now present.
🚨 The stamp survives only on a content type that DECLARES the map as
ImmutableDictionary<string,string>? StepUpReceipts: the endpoint round-trips a typed content
through its own type, and a type without the property drops the stamp — the approval then has no
receipt, which fails closed. Every approval content declares it: InstanceActionContent,
OperationRequestContent, ActivityContent, InstanceRequestContent (MeshWeaver.Plugins) and
the core Approval record behind the generic _Approval satellite. A type whose generated record
equality would compare the map by REFERENCE must compare it by entries instead — Approval
overrides Equals for exactly that, because its control plane writes only "while the node is still
the revision it judged", and two reads of one stamped approval must be equal for that to hold.
A consumer never looks a receipt up by a computed id: reading an absent node is a framework defect (the routing not-found opens the storm-breaker on that path). The receipt is read only once a stamp names it, and by then it exists.
The verdict — one function, every consumer
IStepUpService.Consume(receiptId, approver, actionPath, binding) answers one StepUpVerdict,
checked in this order:
| Outcome | When |
|---|---|
NotRequired |
step-up is not enabled on this instance (policy off) |
Missing |
no receipt is stamped for this approver, or the stamped id does not resolve |
Unavailable |
the receipt read did not answer — fail closed, never read as "absent" |
Invalid |
the seal does not verify (tampered, or another instance's key) |
WrongUser |
the receipt belongs to someone else |
WrongAction |
no target names this action path |
WrongBinding |
the target's hash is not the hash being approved — the plan or script changed since the approver saw it |
Expired |
past ExpiresAt |
Replayed |
the consumption marker already exists |
Accepted |
consumed now; carries the method |
Anything but Accepted/NotRequired parks the approval with a localized reason and a
Confirm with step-up button; it is never treated as a refusal of the request itself, and never
as a pass. Consumption is the LAST check before the action proceeds, so a receipt is not burned by
an approval some other gate parks.
Enablement — declared per deployment record, off until declared
| Portal configuration key | Deployment record field (ApprovalStepUp) |
Default | Meaning |
|---|---|---|---|
Authentication:StepUp:Enabled |
Enabled |
false |
Every approval requires a receipt. Off ⇒ every consumer answers NotRequired |
Authentication:StepUp:Entra:AuthenticationContext |
EntraAuthenticationContext |
— | The Conditional Access authentication context id (c1…c99) requested for Microsoft accounts. Enabled without it ⇒ Microsoft accounts are refused with "step-up is not configured", never waved through |
Authentication:StepUp:Entra:TenantId |
EntraTenantId |
Authentication:Microsoft:TenantId |
The tenant whose tid the step-up token must carry. Required when the sign-in tenant is common/organizations |
Authentication:StepUp:Entra:RequireAmr |
EntraRequireAmr |
true |
Require an amr claim naming a phishing-resistant method. false accepts acrs alone — only the Conditional Access policy behind the context then vouches for the strength |
Authentication:StepUp:Entra:PhishingResistantAmr |
EntraPhishingResistantAmr |
fido,hwk |
The amr values that count as phishing-resistant (comma-separated). Not ngcmfa (Entra also emits it for an Authenticator push) and not x509 (single-factor certificate) |
Authentication:StepUp:MaxAuthAgeSeconds |
MaxAuthAgeSeconds |
120 |
How old auth_time may be when the token arrives |
Authentication:StepUp:ReceiptLifetimeSeconds |
ReceiptLifetimeSeconds |
300 |
How long a receipt stays consumable |
Authentication:StepUp:AllowTotpFallback |
AllowTotpFallback |
true |
Whether the TOTP rung exists at all |
Step-up also needs the instance master key (IMasterKeyProvider) — it keys the seal. An instance
without one refuses to mint, naming the missing key.
Switching it on for an Entra tenant — the admin steps
Done once per tenant by a Conditional Access Administrator (Entra ID P1 or higher). Nothing in the portal changes behaviour until the last step.
- Create the authentication context. Entra admin center → Protection → Conditional Access
→ Authentication contexts → New authentication context. Name
MeshWeaver approval, IDc1(any freec1–c99), tick Publish to apps. Note the ID. - Make sure a phishing-resistant method is enabled. Protection → Authentication methods
→ Policies → enable Passkey (FIDO2) (and/or Windows Hello for Business, certificate-based
authentication) for the approvers. Each approver registers one at
https://mysignins.microsoft.com/security-info. - Create the Conditional Access policy. Conditional Access → Policies → New policy:
- Users: include the approvers (e.g. the platform-admin group); exclude your break-glass accounts.
- Target resources: choose Authentication context → select
c1. - Grant: Require authentication strength → Phishing-resistant MFA.
- Session: Sign-in frequency → Every time.
- Enable policy: Report-only first, check the sign-in logs, then On.
- Register the callback and emit the claims. App registrations → the portal's sign-in app (the
Authentication:Microsoft:ClientId):- Authentication → Web → Redirect URIs → add
https://<portal host>/auth/step-up/callback(next to the existing/signin-microsoft). - Token configuration → Add optional claim → ID → tick
acrs,auth_timeandamr. All three are v2.0 optional ID-token claims: withoutauth_timeevery step-up failsauth_time, and withoutamrit failsamr(the defaultRequireAmr = true).
- Authentication → Web → Redirect URIs → add
- Declare it on the deployment record (control instance,
Deployments/<name>):ApprovalStepUp.EntraAuthenticationContext = "c1", thenApprovalStepUp.Enabled = true(fluent:WithApprovalStepUp(true, "c1")), and roll.
🚨 Without step 3, Entra issues the acrs claim for an unprotected context to anyone who signs
in — Microsoft's own table: "ACRS requested, no policy assigned → ACRS added to claims". The
acrs check is only as strong as the policy behind it, which is why the portal ALSO requires an
amr naming a phishing-resistant method (fido for a FIDO2 key or a passkey, hwk for Windows
Hello for Business) — Microsoft's AMR table maps an Authenticator push to rsa, ngcmfa, mfa, a
password to pwd, so neither passes.
The Entra rung — exactly what the server checks
GET /auth/step-up?target={path}&binding={hash}[&target=…&binding=…]&returnUrl={local}
- Signed in, step-up enabled, the account signed in through the
Microsoftscheme — the session'smw_idpclaim, set from the sign-in TICKET the challenged scheme produced, never from the callback route. A session from before that claim existed is asked to sign in again — the provider is never guessed. - The pending step-up — state, nonce, the targets, the return URL, the user, ten minutes — is
stored SERVER-side at
Auth/_StepUpPending/{handle}(System-only); the browser carries only the handle and the state, sealed with Data Protection (a bulk approval's targets would overflow a cookie). The callback reads it once and deletes it. - Redirect to
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorizewithprompt=login,login_hint= the signed-in account, a freshnonce, andclaims={"id_token":{"acrs":{"essential":true,"value":"c1"}}}.
GET /auth/step-up/callback?code&state exchanges the code at the token endpoint and validates the
id_token:
| Check | Refused when |
|---|---|
| signature | not signed by a key in the tenant's published JWKS |
iss / tid |
not https://login.microsoftonline.com/{tid}/v2.0 for the configured step-up tenant |
aud |
not the portal's client id |
exp / nbf |
outside the token's lifetime |
nonce |
not the nonce of THIS pending step-up |
| subject | oid differs from the session's oid (or, for an older session, the token's preferred_username/email differs from the signed-in account) |
auth_time |
missing, or older than MaxAuthAgeSeconds |
acrs |
does not contain the declared context |
amr |
absent (unless the record waives it with RequireAmr = false), or without a phishing-resistant value (fido, hwk by default) |
Then the receipt is minted, each target stamped, and the browser returned to returnUrl with
stepUp=done (or stepUp=failed&reason=…).
The passkey rung (non-Microsoft accounts)
Where a factor lives: ONE System-only node per account, Auth/_StepUpFactors/{user}/factors
(StepUpFactors) — the passkeys (credential id, COSE public key, user handle, signature counter,
AAGUID, created/last-used) and, if any, the TOTP enrolment. Never a private key, never a plaintext
secret. Whether it exists is learned from a scope:children listing of Auth/_StepUpFactors/{user}
— never a point read of a path that may be absent. The FIRST factor is a CREATE that refuses a node
already there (and reads the stored node back to see whether its own write landed); every further
factor is a GetMeshNodeStream(path).Update(fold) onto the existing node. The two never fall back
into each other, because WHICH one is allowed is an authorization decision: a stale "no factors"
listing, or two enrolments at once, must not turn a first-factor create into adding a second factor
without the step-up that adding one requires. The passkeys are a JSON OBJECT keyed by credential
id, never an array: a cross-hub update ships an RFC 7396 merge patch, which replaces an array
WHOLE, so two replicas folding stale copies of a list could drop a newly enrolled credential or move
another one's counter back. Keyed, each credential (and each of its fields) is patched on its own.
- Enrolment at
/auth/step-up/enroll, reached from Settings → Security (a person-app tab of framework controls with one button; the ceremony itself must run in the page that asks for it).navigator.credentials.createagainst/auth/step-up/passkey/register/optionsand…/register; the server verifies the attestation with the maintained FIDO2 library (Fido2NetLib), user verification required, attestationnone, existing credentials excluded. Who may enrol: the FIRST factor only within ten minutes of a sign-in (the session'smw_auth_time); every further one only after a step-up WITH an existing factor — a receipt for the targetAuth/_StepUpFactors/{user}/factors, bindingenroll, checked when the options are issued and CONSUMED at the write — so a stolen session cannot add its own authenticator. A Microsoft account enrols nothing here — nor does a session that predates the provider claim: every enrolment endpoint refuses it, and the enrolment page tells a Microsoft account that Microsoft's own sign-in confirms its approvals. A portal factor on a Microsoft account would be a second way in around Entra's phishing-resistant prompt. - Assertion at step-up:
/auth/step-uprenders one button;navigator.credentials.getwith a challenge DERIVED from the pending step-up — SHA-256 over the user, every target and the nonce — so an assertion made for one approval can never confirm another.userVerification=required. The library verifies signature, origin, RP id, challenge, the UV flag and the signature counter; then the counter is stored and the same receipt minted withmethod=passkey. The counter policy: a NON-ZERO counter must move forward (a counter that did not is refused as a possible clone), and each non-zero value is CLAIMED in the store before the receipt is minted (markerAuth/_StepUpUse/pk-{user}-{credential}-{count}), so two assertions carrying the same counter — a cloned authenticator used in two ceremonies at once — yield one receipt, not two. Zero is the WebAuthn "this authenticator keeps no counter" value (synced passkeys report it on every assertion, so0 → 0is accepted): clone detection cannot apply to such an authenticator, and each of its assertions is bounded by the single-use pending step-up its challenge is derived from.
What every completing endpoint re-checks
The page decides nothing; each endpoint that can yield a proof — the Entra callback, the passkey verification, the TOTP verification — checks, in this order:
- The pending step-up is TAKEN, not read. It is claimed with the same store primitive the
receipt consumption uses: a marker node
Auth/_StepUpUse/pending-{handle}carrying a fresh nonce, read back, and the claimant goes on only when the stored nonce is its own. Two requests carrying the same cookie at once yield at most one proof. The pending node is deleted afterwards as tidying; the claim, not the delete, is the guarantee. - The rung the server recorded matches.
StepUpPending.Rungholds the rung decided at the start, andStepUpLadder.MayCompleterequires it to be this endpoint's method AND the ladder, decided again now on the session's provider and the account's current factors, to land on it too. A ceremony started for Entra can therefore never be finished with a portal passkey or TOTP code, whatever factors the account holds; a record written before the field existed completes nothing. - A TOTP step or recovery code is CLAIMED before the receipt is minted — markers
Auth/_StepUpUse/totp-{user}-{step}and…/rc-{user}-{hash}— because validating the code against a snapshot and folding the counter afterwards lets two concurrent confirmations both pass. A non-zero passkey counter is claimed the same way (above). - A TOTP or recovery-code attempt spends the user's attempt budget first — before the code is
even looked at. Taking the pending step-up allows one guess per ceremony, but ceremonies are free
to start, so without a budget a stolen session could keep guessing six digits. Each attempt claims
one of five slots of the current 15-minute window (
Auth/_StepUpUse/totp-try-{user}-{window}-{slot},StepUpSingleUse.ClaimTotpAttempt): atomic per slot, durable across restarts and replicas, and counted across ceremonies. With no slot left the attempt is refused (locked) until the next window.
The TOTP rung (only where no passkey is possible)
The ladder offers TOTP only to an account with no passkey and an authenticator app enrolled,
and the enrolment page offers to SET UP an authenticator app only when the browser reports no
platform authenticator (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()) or
no WebAuthn at all. The server enforces the half it can see, at every endpoint: an account with a
passkey is never offered TOTP, cannot enrol it, and a code is refused for it (downgrade) — so the
fallback cannot be used to step around a passkey. Authentication:StepUp:AllowTotpFallback = false
removes the rung entirely.
- Enrolment: a QR code of the
otpauth://URI (rendered server-side as SVG) plus the base32 key; the secret waits in a sealed cookie until the first valid code confirms it, then is stored encrypted withIProviderKeyProtector(the instance master key) — never in configuration. Ten one-time recovery codes are shown ONCE and stored as SHA-256 hashes. - Verification: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1 step), compared in constant time; each
time step is accepted once and a recovery code once — decided by the single-use claim above, with
LastTotpStepmoved forward and the code's hash removed afterwards. One attempt per confirmation — a wrong code ends the pending step-up, so codes cannot be guessed inside one.
Consumers
| Approval path | Where the receipt is consumed | Binding |
|---|---|---|
Hosting/InstanceAction Approve |
ActionsExecutor.ApprovalGate, consumed in the control plane just before the run |
ApprovedPlan (plan digest) |
Essentials/OperationRequest Approve |
OperationRequestControlPlane.ApproveFlow, before the claim |
ScriptHash |
Governance/Activity signatures |
ActivityGates.SignatureRefusal, consumed with the signature |
the activity ContentHash |
Hosting/ApprovalInbox bulk approve |
inherits — one step-up covering every selected target | each row's own hash |
Approvals/Approval (the generic _Approval satellite) Approve / Reject |
ApprovalControlPlane, consumed just before the decision is ratified as System |
Approval.StepUpBinding() — the approval's terms AND the decision |
Hosting/InstanceRequest Approve / Refuse |
InstanceRequestControlPlane, consumed after every other gate, before the decision runs |
InstanceRequestContent.StepUpBinding() — the request as filed AND the decision |
Governance/Activity manual-gate answers |
the activity control plane, consumed when the answer is admitted | the gate, the answer and the activity ContentHash |
A decision whose receipt does not count yet is held, never refused: nothing is written back (a
platform write would move the node past the decider's own revision), so the step-up stamp — the
decider's next write — re-triggers the judgement with the receipt present. The page that wrote the
decision leads straight on to /auth/step-up; for a decision written by hand (MCP, the edit form)
the control plane's log line names the step-up URL to open.
SoleMaintainerApproval stays what it is — may this person approve their own request — and the
receipt is required on top of it, never instead of it.
Status
| Piece | State |
|---|---|
| receipt, seal, consumption, verdict, node types | core — first change |
Entra rung, mw_idp/mw_oid/mw_tid/mw_auth_time on the session cookie, record keys |
core — first change |
| passkey rung, TOTP rung, Settings → Security | core — second change |
| consumers | MeshWeaver.Plugins — after the core contract is in a sealed set |
Approval.StepUpReceipts + Approval.StepUpBinding() (the generic satellite's half) |
core — third change |
public links (link.publish) consuming the receipt |
Refs #4306, after the consumers |
Related
- Authorize as Caller, Execute as System — the approval is authorized as the caller; the receipt is part of that authorization.
- Sole-Maintainer Approval — who may approve; this page is about proving it is really them, just now.
- Access Control — node-type access rules.
- Instance Secrets — the master key the seal and the TOTP secret derive from.
- Policy Not Prose — the
approval-step-uprow.