Skip to content

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.

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.

TypeMeaning
mfaMulti-factor authentication confirmation
human_approvalA human reviewer must approve the action
software_attestationSoftware 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.

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 mandate

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.

When the STS detects step_up_required, it creates a challenge row with:

ColumnContent
idUUIDv7
zone_idZone of the exchange
session_idSession ID of the requesting agent
principal_idApplication or user ID of the requester
challenge_typeValue from the policy diagnostic
challenge_secret_hashSHA-256 of the base64url-encoded 32-byte random secret
resource_set_hashSHA-256 of the canonical (sorted, lowercase) resource list
expires_atCreation time + 5 minutes
satisfied_atNull until satisfied
consumed_atNull 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.

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}/satisfy

This 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.

The client includes two additional fields in the token exchange form body:

FieldValue
challenge_idThe UUID from the 401 response
challenge_responseThe 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_challenges
SET consumed_at = $now
WHERE 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".

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.

The step-up flow produces audit events at each decision point:

evaluation_statusdecisionHTTP StatusTrigger
(from policy)deny401Policy returned step_up_required; challenge created
challenge_invaliddeny401Verification failed (bad secret, wrong resources, expired, already consumed)
challenge_cooldowndeny429Principal exceeded failure threshold and is in cooldown
(from policy)allow200Challenge 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.

Step-up does not:

  • Perform MFA, request human approval, or contact any external verifier. The STS only stores the challenge and checks whether satisfied_at was set.
  • Guarantee that the entity that satisfied the challenge is the same entity that initiated the exchange. That relationship is enforced by principal_id matching, 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_hash binds the challenge to the exact set of resources in the original request.
  • Authority Model — the OPA input structure and how challenge_resolved appears in input.context
  • Policy — how to write policies that guard high-value scopes behind step-up
  • Audit Ledger — how step-up events are recorded and chained