Provenance

Guide: agent authorization

Authorize binds a real, present human to a hash of exactly what an AI agent is about to do. AP2 proves agent→agent trust; Authorize proves a human started the chain. Set $PROVENANCE_API and $PRV_KEY from the Quickstart first.

1. Authorize an action

The human approves; you mint a token. The action is free text — the endpoint neither knows nor cares whether it's a payment, an email, or a deletion.

curl $PROVENANCE_API/v1/intent/authorize \
  -H "Authorization: Bearer $PRV_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "action_description": "Transfer $1,850 to acct ****4021", "agent_session_id": "sess_abc", "biometric_present": true, "device_id": "00000000-0000-0000-0000-000000000001" }'
{ "intent_token": "eyJhbGciOiJIUzI1Ni…", "expires_at": "…", "attestation_id": "…" }

The token is a JWT whose payload includes action_hash = SHA256(action_description), biometric_bound, hardware_attested, and a 4-hour expiry.

2. Verify before executing

Right before the agent executes, verify the token against the action it's about to run:

TOKEN=$(curl -s $PROVENANCE_API/v1/intent/authorize \
  -H "Authorization: Bearer $PRV_KEY" -H "Content-Type: application/json" \
  -d '{ "action_description": "Transfer $1,850 to acct ****4021", "agent_session_id": "sess_abc", "biometric_present": true, "device_id": "00000000-0000-0000-0000-000000000001" }' \
  | grep -o '"intent_token":"[^"]*"' | cut -d'"' -f4)

curl $PROVENANCE_API/v1/intent/verify \
  -H "Authorization: Bearer $PRV_KEY" -H "Content-Type: application/json" \
  -d "{ \"intent_token\": \"$TOKEN\", \"action_description\": \"Transfer \$1,850 to acct ****4021\" }"
{ "valid": true, "hardware_attested": false, "biometric_bound": false, "scope_match": true, "payload_bound": false }

If the description doesn't match the token's action_hash, valid is false — the agent is trying to execute something other than what the human approved.

Execution binding

Pass action_payload at authorize time: the exact machine payload the agent will run, such as an AP2 mandate or an API body. The token then carries payload_hash, and at verify time the caller must present the same payload or verification fails with payload_hash_mismatch. This closes the gap between what the human approved and what the agent actually runs, so an agent that mutates the payload after approval (prompt injection, hallucination) fails.

Biometric binding

biometric_bound means a real platform-authenticator ceremony (Face ID / Touch ID / Windows Hello / Android fingerprint) was performed and verified server-side — never a self-asserted flag. The legacy biometric_present boolean is deprecated and no longer binds anything.

To bind a biometric to an authorization:

  1. Register a platform credential once, at device enrollment: get a challenge from POST /v1/admin/devices/enrollment-challenge, run WebAuthn navigator.credentials.create (userVerification: "required"), and pass the result to POST /v1/admin/devices as webauthn_registration.
  2. At authorize time, get a challenge from POST /v1/intent/biometric-challenge, run navigator.credentials.get over it (the Face ID or Touch ID prompt), and include the assertion:
{ "action_description": "…", "agent_session_id": "…", "device_id": "…",
  "biometric_challenge": "<challenge>",
  "biometric_assertion": { "authenticator_data": "…", "client_data_json": "…", "signature": "…" } }

The server verifies the assertion (signature by the enrolled credential over the exact challenge, matching origin/RP ID, User-Verified flag set) and only then mints a token with biometric_bound: true. A missing, forged, or replayed assertion yields false. The native SDK path (Android BiometricPrompt / iOS Secure Enclave) produces the same assertion shape.

Consumer policies

/v1/intent/verify accepts an optional context object with caller policy inputs. Two demonstration rules — not part of the primitive:

Callers layer their own action-type-specific rules on top of the base result.

One primitive, any action

The identical authorize→verify flow works for any consequential action:

Same code, same token, same verification. The binding is to a hash of the action — not to any payment-specific schema.

Using an agent framework? The recommended path is a drop-in, not hand-written authorize/verify calls. @provenance/agent-adapters gates an MCP / Vercel AI SDK / LangChain tool in a few lines — same guarantees, less plumbing.

Independently verifiable

Tokens are signed with ES256 and the public key is published at /.well-known/jwks.json, so a relying party such as a bank, a merchant or another agent can verify a token offline, without an API key. See Verifying tokens independently. Offline verify proves authenticity + expiry; action/payload binding and revocation still require POST /v1/intent/verify.

Revocation

An intent token lives for 4 hours. That's a long time if a session goes rogue or a device is lost, so every token carries a jti (the attestation_id returned by authorize) that you can revoke. Revocation is effective immediately on the next verify — a revoked token fails with reason: "token_revoked" and a revoked_scope telling you which kill-switch fired.

Three scopes, one endpoint (POST /v1/intent/revoke) — provide exactly one selector:

curl -X POST https://sandbox.provenance.io/v1/intent/revoke \
  -H "Authorization: Bearer prv_test_..." -H "Content-Type: application/json" \
  -d '{ "agent_session_id": "agent-42", "reason": "anomalous spending" }'

Revocation is issuer-scoped: revoking by token only works for tokens your own account/carrier issued (else 403 revoke_forbidden). Calls are idempotent — re-revoking returns the existing record. In the SDK: prov.intent.revoke({ agentSessionId: "agent-42", reason: "…" }).

For a relying party that verified offline against the JWKS and never calls our API, there's a cheap online check: GET /v1/intent/status/:jti (prov.intent.status(jti)) returns { revoked, revoked_scope, reason }. High-value actions should call it — offline verify alone cannot see revocations.

The dashboard exposes this too: Account → Sessions & agents has a revoke form (paste a token/jti or a session/device id).

Step-up approval

Sometimes a policy shouldn't deny — it should ask a human. A rule with require: { approval: true } turns a terminal deny into an interactive push-to-approve loop: the agent equivalent of Stripe 3DS or Okta Verify.

Agent ─POST /v1/intent/verify──▶ policy matches an `approval` rule
        ◀── { valid:false, reason:"approval_pending", approval_id, expires_at }
                                   │
                         human's dashboard inbox lights up
                         (approve — with Face ID if the rule also
                          demands biometric — or deny)
                                   │
Agent ─POST /v1/intent/verify──▶ approval satisfies the rule
        ◀── { valid:true, approval_id, approved_at }

Add the rule in your policy (see policies → require.approval):

{ "id": "wire-needs-human", "when": [{ "fact": "action_type", "op": "eq", "value": "wire" }],
  "require": { "approval": true, "biometric": true } }

biometric: true alongside approval means the approval response must carry a verified Face ID / Touch ID assertion — not the token's own biometric. The human decides in the dashboard Approvals inbox; a biometric-gated rule shows an Approve with Face ID button.

The agent just re-verifies (or polls GET /v1/approvals/:id). The SDK wraps the loop:

const result = await prov.intent.verifyWithApproval(
  { intentToken, actionDescription: "wire $1,850", context: { actionType: "wire" } },
  { pollMs: 2000, timeoutMs: 120000 },
);
// resolves once valid, or on approval_denied / approval_expired

Guarantees: the approval is bound to the exact (jti, action_hash, payload_hash), so a mutated payload never inherits an approval (it fails payload_hash_mismatch first). Approvals expire (default 10 min → approval_expired), a deny is terminal (approval_denied), and revoking the token still wins (token_revoked, because revocation runs before policy).

Delivery: phone, push, email

The killer UX is out-of-band: the human's phone buzzes with exactly what the agent wants to do, they tap the link, and confirm with Face ID / fingerprint. Provenance delivers each approval across the account's preferred channels (Account → Approval delivery), tried in order, first success wins, with email + the dashboard inbox as the always-on fallback:

An unconfigured transport reports channel_not_configured and delivery falls through to the next channel. GET /v1/auth/me reports each channel's readiness under channels (configured on this server, ready for this account, and a reason: provider_not_configured, phone_not_verified or browser_not_subscribed), and the settings page labels each channel with it. A channel the server can't deliver on can't be switched on.

The link opens a mobile approval page (/approve/:token) showing the full detail (description, amount or context, and an expiry countdown) with Approve (biometric) / Deny. The link token is a scoped bearer for that one approval (never a session), single-use for the decision. Reopening a used link (until it expires) shows only the outcome, such as "You denied this at 14:02": the status and time, never the action text or who decided. Approve runs the same WebAuthn ceremony as authorize; a biometric-gated rule can't be approved without a verified assertion.

Passkey on the phone: the WebAuthn credential must be usable where you approve. Synced passkeys (iCloud Keychain / Google Password Manager) cover same-ecosystem cases; otherwise the page prompts you to set up a passkey on this device, and cross-device QR (CTAP hybrid) works as the fallback.

Why not approve by SMS reply? A "YES" reply is spoofable and carries no proof of presence. The signed single-use link + on-device biometric is the security model — replying to the message does nothing.

Audit trail

Every authorize and verify is recorded in an append-only, hash-chained decision ledger — the facts, the result, the matched rule, and the policy version, queryable months later to prove a human authorized this. prov.decisions.list(...) / the dashboard Decisions view read it; verify-chain proves it wasn't edited.

Getting notified

Instead of polling, subscribe a webhook to approval.requested / approval.resolved / intent.revoked / intent.denied and Provenance pushes a signed delivery when each happens.

Registering agents

The first question platform teams ask is "which of my 40 agents did this?" Register an agent to turn the free-text agent_session_id into a first-class identity — with attribution, a kill-switch, and analytics.

const agent = await prov.agents.create({ name: "invoice-bot" });
await prov.intent.authorize({ actionDescription: "Pay $500", agentSessionId, deviceId, agentId: agent.agentId });

The agentId flows into the token (agent claim), the decision ledger, receipts, and deny alerts ("your agent invoice-bot was blocked"). Then:

Registration is optional and additive: unregistered agentSessionId flows are byte-identical to before. And to be precise about what this is: agent identity is a label for control and audit — we cryptographically attest the human and device, not the agent software itself.

Receipts

Every decision can produce a portable, cryptographic receipt — "a human authorized this; check it yourself." A receipt is an ES256 JWS signed with the same key as intent tokens, so a counterparty verifies it offline against the JWKS (or on the public verify page) with no API key and no trust in us.

const { receipt, token, verifyUrl } = await prov.decisions.receipt(decisionId);
// receipt.result, receipt.record_hash, receipt.biometric_bound, …
// token: an ES256 JWS;  verifyUrl: /verify?receipt=<token>

GET /v1/decisions/:id/receipt returns JSON by default, or a one-page PDF cover with ?format=pdf. Receipts are derivable, never stored — regenerated deterministically from the ledger row (the claim set is a pure function of the row). Growth+ (Developer: a monthly Developer allowance of receipts).

Deny alerts

Tell your humans when an agent is stopped: your agent tried to wire $12k and was blocked. When a verify is denied (a POLICY_* / MANDATE_* / token_revoked / velocity reason), Provenance notifies your humans — email always, and SMS/WhatsApp if you opt that event in. Deny notifications are free on every plan.

await prov.account.setNotifyOn(["approval_requested", "deny", "velocity"]);

A per-account digest window caps deny SMS to one per N minutes (default 15) — email is never suppressed. The intent.denied webhook fires too.

Customizing the message

The SMS/WhatsApp body is a per-account template with {{action}} and {{link}} slots:

await prov.account.setNotifyTemplate("Approve your agent: {{action}} {{link}}");

The template is validated (must contain {{link}}, ≤320 chars, no URL or phone number outside the {{link}} slot), and the interpolated {{action}} is sanitized: URLs and phone numbers are stripped and the text is truncated to about 80 characters. The brand prefix and the approval link always come from us. The approval page always shows the full, hash-bound action_description, so the SMS text can only ever be a safe subset of what is biometrically approved. Pass an optional short display_summary on authorize to control the SMS wording without touching the canonical description.

Slack

Send approval requests and blocked-agent alerts to a Slack channel — where enterprise teams actually live. A Slack message ("Agent invoice-bot requests: wire $1,850 to Acme — [Review & approve]") makes Provenance visible daily inside your org; every approval is an internal ad. Growth+ (Developer: a monthly Developer allowance).

Slack is delivery only — the biometric approval always happens on the Provenance /approve page. A Slack button click is not an approval (that would defeat the security model), so v1 needs no Slack interactivity endpoint.

Configure a per-account destination — an incoming-webhook URL (validated https + hooks.slack.com) or a bot token + channel:

curl -X PUT $PROVENANCE_API/v1/account/slack \
  -H "Authorization: Bearer $PRV_KEY" -H "Content-Type: application/json" \
  -d '{ "webhook_url": "https://hooks.slack.com/services/T00/B00/xxxx" }'

# or a bot token + channel (install the app from integrations/slack/manifest.yaml):
#   { "bot_token": "xoxb-…", "channel": "#approvals" }

curl -X POST $PROVENANCE_API/v1/account/slack/test -H "Authorization: Bearer $PRV_KEY"
# → { "status": "sent" }  |  "skipped" (not configured)  |  "failed" (delivery error)

The message body carries the sanitized action summary (same URL/phone scrubbing as SMS), the agent name, amount/context, an expiry, and one Review & approve button. Channel choice per event type reuses notify_on; deny alerts respect the same digest window. Delivery is fire-and-forget with two retries and never blocks the approval pipeline; an unconfigured or failing Slack is skipped and logged.

Context binding — "approve this BECAUSE…"

An approval is a better decision when the human sees why the agent is asking. Attach a justification at authorize and it is hashed into the token (context_hash, the same canonical-hash construction as payload binding), sealed onto the ledger and the receipt, and shown to the human in a "Why the agent is asking" panel (on the approval page and in <provenance-approve>).

await prov.intent.authorize({
  actionDescription: "Wire $1,850 to Acme Corp",
  agentSessionId, deviceId,
  contextEvidence: { invoice: "4411", reason: "net-30 due today", model: "gpt-x" },
});

Scope: the context is the agent's stated reason. Provenance binds and attributes it; your reviewers judge it. The canonical action description remains the authority; the UI labels the panel "the agent says…" and marks it not verified by Provenance. The display is sanitized (plain text / key-value only — no markup executes).

Thread the same context through verify and it is re-checked against the token's hash:

await prov.intent.verify({ intentToken, actionDescription, contextEvidence: { invoice: "4411", … } });
// a mutated blob → reason: "context_hash_mismatch"

Context binding is free (it rides authorize). The framework adapters (gate({ contextEvidence })) and the MCP server (authorize_action's context) both pass it through; with the agent registry the panel composes ("invoice-bot says…").