Step-Up Challenge
Some resources are sensitive enough that a policy should require fresh proof before issuing a mandate — even when the requesting session and grant are otherwise valid. Step-up challenges are the mechanism for that. A policy signals that a proof is required; the STS creates a time-limited challenge; an external system satisfies it; the client retries the exchange; and the STS atomically consumes the proof before calling OPA again. OPA’s second evaluation sees challenge_resolved = true and can allow the request.
Why step-up exists
Section titled “Why step-up exists”Mandate issuance is evaluated against the policy set active at exchange time. A policy can inspect anything in the OPA input — session depth, delegation chain, requested scopes, time of day — but it cannot reach outside OPA to prompt a human, trigger an MFA flow, or call an external approver. Step-up is the bridge: the policy returns a signal, and the STS pauses issuance while the external interaction completes.
The challenge carries no data about what proof means. challenge_type tells the client which interaction to initiate, but the STS only verifies that the proof was delivered to the right endpoint and then consumed exactly once.
Challenge types
Section titled “Challenge types”| Type | Meaning |
|---|---|
mfa | Multi-factor authentication confirmation |
human_approval | A human reviewer must approve the action |
software_attestation | Software identity or attestation assertion |
The challenge type is purely a signal. The STS does not perform the MFA check or contact any approver — it only stores whether the challenge was satisfied through the API.
Step-up flow
Section titled “Step-up flow”1. Client → STS: exchange request (no challenge fields) ↓2. STS evaluates OPA → policy returns: decision = "deny" diagnostics = [{"step_up_required": "mfa"}] ↓3. STS creates challenge (UUIDv7 id, 32-byte random secret, 5-minute TTL) ↓4. STS → Client: HTTP 401 WWW-Authenticate: Bearer error="interaction_required" { "error": "interaction_required", "error_description": "Step-up authorization required for this resource", "challenge_id": "...", "challenge_type": "mfa", "challenge_secret": "<base64url-32-bytes>", "challenge_expires_at": "...", "requestId": "..." } ↓5. Client initiates MFA/approval flow out-of-band ↓6. External system → API: POST /v1/zones/{zoneId}/step-up-challenges/{id}/satisfy → sets satisfied_at; returns { "id": "...", "satisfied_at": "..." } ↓7. Client → STS: retry exchange with challenge_id + challenge_response ↓8. STS: rate-limit check → verify + atomically consume challenge ↓9. STS evaluates OPA again with challenge_resolved = true in context ↓10. OPA → allow → STS issues mandateHow policy signals step-up
Section titled “How policy signals step-up”A step-up policy guards by checking challenge_resolved and returning a structured diagnostic when it is not yet set:
result := { "decision": "allow", "evaluation_status": "complete", "determining_policies": ["payments-transfer-policy"], "diagnostics": [],} if { input.resource.identifier == "resource://payments" "transfer" in input.context.requested_scopes input.context.challenge_resolved == true}
result := { "decision": "deny", "evaluation_status": "complete", "determining_policies": ["payments-transfer-policy"], "diagnostics": [{"step_up_required": "mfa"}],} if { input.resource.identifier == "resource://payments" "transfer" in input.context.requested_scopes not input.context.challenge_resolved}The STS detects step-up by scanning diagnostics for any object with a step_up_required key. The value of that key becomes the challenge_type. The STS will only start a challenge when the policy also returns decision = "deny" — a policy that returns "allow" with a step_up_required diagnostic is ignored.
Challenge creation
Section titled “Challenge creation”When the STS detects step_up_required, it creates a challenge row with:
| Column | Content |
|---|---|
id | UUIDv7 |
zone_id | Zone of the exchange |
session_id | Session ID of the requesting agent |
principal_id | Application or user ID of the requester |
challenge_type | Value from the policy diagnostic |
challenge_secret_hash | SHA-256 of the base64url-encoded 32-byte random secret |
resource_set_hash | SHA-256 of the canonical (sorted, lowercase) resource list |
expires_at | Creation time + 5 minutes |
satisfied_at | Null until satisfied |
consumed_at | Null until the exchange retry consumes it |
The raw challenge_secret (base64url-encoded 32 bytes) is returned in the 401 response and never stored. Only its SHA-256 hash is persisted.
Satisfying the challenge
Section titled “Satisfying the challenge”The external system — MFA service, approval workflow, or attestation verifier — calls the satisfaction endpoint after the user or reviewer completes the required interaction:
POST /v1/zones/{zoneId}/step-up-challenges/{id}/satisfyThis sets satisfied_at on the challenge row. The endpoint returns 404 if the challenge does not exist, has already been satisfied, or has expired. Satisfaction is idempotent from the row’s perspective but will 404 on a second call.
The challenge is not consumed at this point. Consumption happens atomically during the exchange retry.
Retrying the exchange
Section titled “Retrying the exchange”The client includes two additional fields in the token exchange form body:
| Field | Value |
|---|---|
challenge_id | The UUID from the 401 response |
challenge_response | The raw challenge_secret from the 401 response |
All other exchange parameters must be identical to the original request — same resources, same scopes, same session.
Challenge verification and atomic consumption
Section titled “Challenge verification and atomic consumption”Before calling OPA on the retry, the STS verifies and atomically consumes the challenge with a single UPDATE:
UPDATE step_up_challengesSET consumed_at = $nowWHERE id = $challenge_id AND zone_id = $zone_id AND principal_id = $principal_id AND challenge_secret_hash = sha256($challenge_response) AND resource_set_hash = sha256(canonical($resources)) AND satisfied_at IS NOT NULL AND consumed_at IS NULL AND expires_at > $now AND EXISTS ( SELECT 1 FROM sessions s WHERE s.id = challenge.session_id AND s.zone_id = $zone_id AND s.status = 'active' AND s.expires_at > $now )All bindings must hold simultaneously. If any check fails — wrong principal, wrong resources, already consumed, expired, or associated session revoked — the UPDATE affects zero rows and the STS returns HTTP 401.
A challenge that passes verification is immediately marked consumed. Replaying the same challenge_id + challenge_response will always fail because consumed_at IS NULL no longer holds.
If verification succeeds, the STS sets challenge_resolved = true in input.context and calls OPA. The policy sees a fully resolved context and, if correctly written, returns "allow".
Rate limiting
Section titled “Rate limiting”The STS tracks failed challenge verifications per (zone_id, principal_id) in memory. After 5 failures within a 2-minute sliding window, the principal enters a 5-minute cooldown. During cooldown, any exchange request with challenge_id set is rejected with HTTP 429 before verification is attempted.
A successful verification resets the failure count for that principal.
Rate-limit state is in-process and does not persist across STS restarts.
Audit events
Section titled “Audit events”The step-up flow produces audit events at each decision point:
evaluation_status | decision | HTTP Status | Trigger |
|---|---|---|---|
| (from policy) | deny | 401 | Policy returned step_up_required; challenge created |
challenge_invalid | deny | 401 | Verification failed (bad secret, wrong resources, expired, already consumed) |
challenge_cooldown | deny | 429 | Principal exceeded failure threshold and is in cooldown |
| (from policy) | allow | 200 | Challenge resolved; OPA allowed the request |
All four outcomes produce events in caracal.audit.events that flow into the append-only audit ledger. The original 401 (challenge created) uses the evaluation status the policy returned in its deny result. The mandate-issued event carries challenge_resolved = true in the exchange context embedded in the mandate’s claims.
What step-up does not do
Section titled “What step-up does not do”Step-up does not:
- Perform MFA, request human approval, or contact any external verifier. The STS only stores the challenge and checks whether
satisfied_atwas set. - Guarantee that the entity that satisfied the challenge is the same entity that initiated the exchange. That relationship is enforced by
principal_idmatching, not by authentication of the caller to the satisfaction endpoint. Protect the satisfaction endpoint with appropriate access controls for your deployment. - Extend the challenge TTL. A challenge that expires before satisfaction or consumption is lost. The client must restart the exchange to obtain a fresh challenge.
- Allow the same challenge to be reused for a different resource set. The
resource_set_hashbinds the challenge to the exact set of resources in the original request.
Next steps
Section titled “Next steps”- Authority Model — the OPA input structure and how
challenge_resolvedappears ininput.context - Policy — how to write policies that guard high-value scopes behind step-up
- Audit Ledger — how step-up events are recorded and chained