Step-up authorization (governance R4b)
Forge can require a fresher, higher-assurance authentication for specific tool calls. When a caller presents an authentication that doesn't meet the tool's decl
Forge can require a fresher, higher-assurance authentication for specific tool calls. When a caller presents an authentication that doesn't meet the tool's declared assurance level, Forge returns an RFC 9470 step-up challenge so the client can re-authenticate at the required level and retry.
This closes the R4b gap in the governance framework — pre-check, the policy engine could ALLOW or DENY per action, but had no third option to demand a higher-assurance authentication.
How it works
- Operator declares per-tool assurance requirements in
forge.yaml security.step_up.tools. - On every
BeforeToolExec, Forge:- Looks up the required
acrfor the tool. - Reads the caller's identity from context (populated by the auth middleware at the request boundary).
- Checks the identity's
acrclaim against the requirement.
- Looks up the required
- On a mismatch, the runtime:
- Aborts the tool call.
- Emits an
auth_step_up_requiredaudit event. - Returns HTTP 401 with:
WWW-Authenticate: Bearer error="step_up_required", acr_values="acr:mfa"
- The caller's SDK sees the challenge, prompts the user for the
higher-assurance authentication (MFA / hardware key / etc.),
and retries with a new bearer token whose
acrclaim now meets the requirement. - Auth middleware validates the token, the retry passes step-up, the tool runs.
Configuration
security:
step_up:
enabled: true
# Per-tool required acr values. Tools not listed have no
# step-up requirement.
tools:
cli_execute: acr:mfa
http_request: acr:mfa
# Optional: ordered assurance hierarchy, lowest first. When
# present, comparison is "index-of-presented >= index-of-required".
# A caller with a stronger acr satisfies a weaker requirement.
# Absent → strict-equal comparison.
acr_hierarchy:
- acr:password
- acr:mfa
- acr:hardwareDefault is off. When enabled: false (or absent), no step-up
hook fires and no config is validated.
Fail-loud on config errors: enabling with an empty tools map,
or listing a tool whose required acr isn't in the hierarchy (typo),
fails startup rather than at first tool call.
ACR claim shape
Forge reads the acr claim from the identity's Claims map — the
raw JWT payload that the OIDC / bearer verifier populated. Values
are opaque strings; Forge doesn't interpret them beyond matching
against the configured requirements. Typical shapes:
"acr:password","acr:mfa","acr:hardware"— internal convention (as used in these docs)."urn:mace:incommon:iap:silver"— InCommon."0"/"1"/"2"— SAML AuthContextClass loa values."phr"/"phrh"— pre-registered OIDC classes.
Coordinate values with your IdP: whatever it stamps on the token, that's what Forge sees.
RFC 9470 challenge shape
The response follows RFC 9470 §3:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer error="step_up_required", acr_values="acr:mfa"
{
"error": "step_up_required",
"tool": "cli_execute",
"required_acr": "acr:mfa",
"reason": "no acr claim presented"
}The error parameter and acr_values parameter are the two RFC
9470 params SDKs key off. The JSON body carries the same shape as
the auth_step_up_required audit event so operators debugging a
challenge can grep for the tool + acr pair in both places.
Audit event
{
"event": "auth_step_up_required",
"task_id": "task-abc",
"correlation_id": "req-xyz",
"fields": {
"tool": "cli_execute",
"required_acr": "acr:mfa",
"presented_acr": "",
"reason": "no acr claim presented"
}
}presented_acr is omitted when the caller had no acr claim at
all. Payload never carries token bytes or full claim maps.
Threat model + limits
Solves:
- Escalation attacks where a caller with a low-assurance session tries to invoke a high-assurance tool.
- Post-hoc audit of "which action required step-up and did the
caller satisfy it?" via
auth_step_up_requiredevents.
Does NOT solve:
- Confused-deputy: a legitimate high-assurance session invoked by an attacker on the same client machine. Step-up doesn't authenticate the human on the retry, only the token.
- acr claim tampering: if the IdP mints a token with a spoofed acr claim, Forge trusts it. Protection lives at the IdP layer.
- Cross-service step-up state: Forge does not maintain a step-up-completed marker across separate tool calls. Every call is evaluated against the current identity's acr; if the required acr expires or a lower-acr session is presented, step-up fires again.
Current coverage
The 401 challenge is emitted from:
POST /tasks/sendREST handler ✅
Not yet:
- JSON-RPC over HTTP — the error surfaces in the JSON-RPC error body but does not translate to an HTTP 401 with the challenge header. Follow-up to plumb the typed error through the dispatcher.
- SSE
/tasks/sendSubscribe— the challenge fires mid-stream, so it lands as a task-failed status event rather than an HTTP response header. Follow-up to design the SSE analogue.
For deployments where JSON-RPC or SSE are the primary surface, use
security.step_up alongside a strict guardrails deny so the
tool never runs even when the challenge isn't the transport-native
signal.
Combining with other governance controls
- R3 intent alignment (#208): fires BEFORE step-up. A tool call the alignment engine denies never reaches the step-up check (correct — the operator's intent policy says the caller shouldn't do this at all).
- R9 JIT credentials (#215): step-up validates the caller's authentication assurance; JIT credentials mint the tool's scope-down credentials. Both fire per tool call — step-up first (fails fast if the caller can't authenticate) then JIT mint (only if step-up admits).
- R5 hash chain (#212) + R6 signing (#213): the
auth_step_up_requiredevent participates in both — chained under prev_hash, signed with the audit kid.