On-Behalf-Of & Intent
An agent almost never acts for itself — it acts for a human. Approval alone answers “may this happen?”; it doesn’t answer “did the person this is for actually ask for it?”. On-behalf-of adds that missing link. Every receipt already carries a chain — authority → consent → approval — and the consent link is the principal’s own attestation, captured before any approver sees the request, then sealed into the receipt where your resource server can demand it offline.
All six flows, animated — drag to orbit · open full-screen
The two intent modes
Section titled “The two intent modes”You declare on-behalf-of at authorize time, and the intent mode decides who vouches and when:
| Mode | Who attests | When | Receipt attestation |
|---|---|---|---|
required | The principal themselves, on their own enrolled device (biometric + device co-signature) | Before the request is visible to any approver — a stage-zero gate | device_sig |
attested | The caller (your backend, an MCP gateway, a merchant) asserts the principal asked | At authorize — no extra round-trip | caller_attested |
required is the strong form: the request is created in status pending_principal, invisible to approvers, and only the principal’s signed confirmation moves it into the normal approval pipeline. attested is the pragmatic form for principals who aren’t enrolled humans (an external_subject — a customer ID, an email) or for gateway-initiated patterns where the caller already holds the intent evidence.
Declaring it at authorize
Section titled “Declaring it at authorize”Add on_behalf_of to the authorize call. Exactly one of human_id (an enrolled human) or external_subject (an opaque ID for someone outside your roster) — never both:
POST /v1/authorize{ "action": "invoices:pay", "plan": { "vendor": "acme-hosting", "amount_minor": 420000, "currency": "INR" }, "on_behalf_of": { "human_id": "hum_9f3c…", "intent": "required" }}Validation is strict: intent: "required" demands an enrolled human_id with at least one registered device — an external_subject can’t hold a signing key, so it can only be caller_attested.
The stage-zero gate: pending_principal
Section titled “The stage-zero gate: pending_principal”With intent: "required" the request parks in pending_principal. The principal gets a push on their own device — “did you ask for this?” — and confirms or declines. The confirmation is a real device co-signature over the same canonical bytes as any decision (plan_hash‖request_id‖huid‖device_id‖decided_at), submitted to:
POST /v1/intent/{request_id} (X-Device-Proof: signed by the principal's device)- Confirm → status moves to
pending; approvers see it for the first time. - Decline (“this wasn’t me”) → the request dies at stage zero. The approvers were never interrupted — a rogue or compromised agent burns nobody’s attention and mints nothing.
- Wrong status →
409; request TTL elapsed →410.
That ordering is the point: consent is captured principal-first, so the quorum only ever votes on requests their colleague already owns.
What lands in the receipt
Section titled “What lands in the receipt”The receipt’s ha.principal claim is serialized before the approvers array — authority, then consent, then approval, sealed together by the platform’s EdDSA JWS:
"ha": { "action": "invoices:pay", "principal": { "huid": "hum_9f3c…", "attestation": "device_sig", "intent_at": 1755590400, "device_id": "dev_c41a…", "device_sig": "…" }, "approvers": [ … ]}For caller_attested, principal carries external_subject (or huid) with attestation: "caller_attested" and no device material.
Enforcing it at the resource server
Section titled “Enforcing it at the resource server”The consent link is only worth something if the executing side demands it. @humanauth/verifier ≥ 0.4.0 adds requirePrincipal:
const verified = await verifier.requireReceipt(jws, { action: "invoices:pay", requirePrincipal: "device_sig", // or "any"});"any"— aprincipalentry must exist (either attestation). Missing →PRINCIPAL_MISSING."device_sig"— the principal’s own device co-signature must be present and verify against the receipt’s canonical bytes. A merely caller-attested receipt fails withPRINCIPAL_ATTESTATION_WEAK.
Both are typed errors in the same family as the rest of the verification fence — the action never executes on a failed check. Policy guidance: gateways executing high-impact actions for enrolled humans should demand device_sig; any fits marketplace patterns where the principal is an external customer.
When it refuses
Section titled “When it refuses”The failure animations above are not decorative — each is a distinct enforcement path:
- Approver denies — consent existed, approval didn’t. No receipt is ever minted; there is nothing to present; the gateway stays locked.
- “This wasn’t me” — the principal declines at stage zero. The request dies
pending_principal; approvers never saw it; a compromised agent gains nothing and alerts the one human who’d know. - Replay rejected — a valid, spent receipt is presented again. The replay store’s atomic
(jti, idempotencyKey)claim fails; one receipt authorizes exactly one execution.
Gateway-initiated approvals (MCP gateways, merchants)
Section titled “Gateway-initiated approvals (MCP gateways, merchants)”The third success flow inverts the caller: the resource server itself initiates authorization before executing a tool call — an MCP gateway pausing a tools/call, a merchant demanding human sign-off before completing a checkout. The gateway calls authorize with on_behalf_of: { external_subject, intent: "attested" } (it holds the intent evidence — the cart, the tool invocation), humans approve, and the same gateway then verifies the receipt it asked for. One party plays both ends; the trust roles — orchestrate vs. enforce — stay distinct.
See also
Section titled “See also”- Architecture — who talks to whom; why enforcement lives at the resource server
- Verifier SDK —
requireReceipt(), replay stores, typed errors - Security Model — the full verification chain the principal check joins