Agent identity delegation

Scoped, time-bound identity delegates for AI agents: how to create, verify and revoke one, how behavioral trust scoring narrows its scopes, and which auth lane each endpoint answers on.

Last reviewed September 18, 2026Fresh

Overview

An identity delegate is a token an AI agent presents to prove that a human authorized it. A tenant admin creates one, hands the agent the token once, and the agent calls verify before it acts. Each delegate carries a trust score between 0.000 and 1.000 that PasskeyBridge lowers when its recent activity looks anomalous, narrowing the delegate's scopes or deactivating it.

Three edge functions are involved, on three different auth lanes.

FunctionAuth laneWho calls it
shield-agent-delegateDashboard session JWT for create, list and revoke; the agent token for verifyTenant admins, and the agent itself
shield-agent-trustService-role key onlyPasskeyBridge internally
shield-a2a-handshakeDashboard session JWT of a tenant memberYour backend, negotiating between two of your delegates

No agent endpoint accepts an API key. There is no agents scope, and x-pb-api-key is not read by any of the three. The authenticated lane is Authorization: Bearer carrying the Supabase session access token of a signed-in dashboard user. The one exception is verify, which is public and identifies the caller by the agent token in the body.

`shield-agent-trust` is internal. It answers 401 to anything that is not the service-role key, with an error field saying the function requires a service-role key.

You see its results in Dashboard > Agents and in the trust_score that verify returns. There is no way to call it yourself: shield-agent-trust compares the Authorization header against the service-role key before it reads the action, so every request carrying an API key answers 401. The API Playground offered an "Evaluate Agent Trust" entry that did exactly that until 2026-09-17, when it was removed.

No plan gate. Nothing in these functions calls a plan check, and the Agents tab is available on every plan. The only hard ceiling is the batch cap described under Limits and quotas.

Delegate lifecycle

Creating a delegate. Admin role in the tenant is required; a member gets 403 Only admins can create agent delegates.

curl -X POST https://api.passkeybridge.io/v1/shield-agent-delegate \
  -H "Authorization: Bearer <dashboard session access token>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "create",
    "agent_label": "Invoice Processor",
    "scopes": ["events:read", "verify"],
    "expires_in_hours": 72,
    "initial_trust": 0.85,
    "auto_revoke_below": 0.2
  }'
FieldTypeDefaultRules
actionstringrequiredcreate
agent_labelstringUnnamed Agent200 characters or fewer
scopesarray of strings[]20 items or fewer, each 50 characters or fewer
expires_in_hoursnumbernone, meaning no expiryGreater than 0 and at most 8760
initial_trustnumber1.0Greater than 0 and at most 1
auto_revoke_belownumber0.2Between 0 and 1 exclusive, and strictly below initial_trust
tenant_iduuidyour oldest membershipMust be a tenant you belong to

Scopes are free strings that you define. PasskeyBridge matches them only in the verify scope check and in the narrowing ladder, which recognizes ingest:read, events:read, playbook:read, intelligence:read and verify and drops anything else when it narrows. The New delegate wizard offers a different vocabulary (ingest, events:read, playbooks:execute, credentials:present, spatial:attest, shadow:create), so a wizard-created delegate keeps only events:read and verify if it is ever narrowed to read-only.

The response is the stored row plus the one-time token:

{
  "delegate": {
    "id": "0f2c9c2e-...",
    "agent_label": "Invoice Processor",
    "scopes": ["events:read", "verify"],
    "original_scopes": ["events:read", "verify"],
    "trust_score": 0.85,
    "auto_revoke_below": 0.2,
    "expires_at": "2026-09-19T12:00:00Z",
    "is_active": true
  },
  "agent_token": "pb_agent_1f8c...",
  "warning": "Store this token securely. It will not be shown again."
}

The token is pb_agent_ followed by two UUID v4 values with the hyphens stripped. Only its SHA-256 digest is stored, in agent_identifier_hash; the plaintext cannot be recovered or reissued.

Verifying. Public, no session needed. The agent presents its token:

curl -X POST https://api.passkeybridge.io/v1/shield-agent-delegate \
  -H "Content-Type: application/json" \
  -d '{
    "action": "verify",
    "agent_token": "pb_agent_1f8c...",
    "tenant_id": "<tenant uuid>",
    "scope": "events:read"
  }'
{
  "authorized": true,
  "agent_label": "Invoice Processor",
  "scopes": ["events:read", "verify"],
  "expires_at": "2026-09-19T12:00:00Z",
  "trust_score": 0.85
}

tenant_id is required and must be a UUID; scope is optional and at most 100 characters. The server hashes the token, requires an active delegate in that tenant, checks expires_at, and checks the scope against the delegate's current list. A delegate whose scopes array is empty passes any scope value, so an empty array means unrestricted rather than restricted. Each call updates last_used_at and records one activity row for trust scoring, both fire-and-forget.

Listing. { "action": "list" } with a session JWT returns every delegate in the tenant, active and revoked, newest first:

{ "delegates": [{ "id": "0f2c...", "agent_label": "Invoice Processor", "scopes": ["events:read", "verify"], "is_active": true, "expires_at": "2026-09-19T12:00:00Z", "last_used_at": null, "created_at": "2026-09-16T09:00:00Z" }] }

The list response does not include trust_score; read the score from verify, or from the Agents tab, which queries the table directly.

Revoking. Admin only. { "action": "revoke", "delegate_id": "..." } sets is_active to false and answers { "revoked": true }. There is no un-revoke: the Restore control in the Agents tab restores narrowed scopes on a delegate that is still active, and a revoked agent needs a new delegate and a new token.

From the dashboard. Dashboard > Agents > New delegate runs the same create call through a five-step wizard (label, scopes, trust thresholds, expiry, create) and shows the token once, on the final step.

Confirm it worked. The new delegate appears as a row in Dashboard > Agents with its label, a trust gauge, its scope count and its auto-revoke floor.

Trust engine

supabase/functions/_shared/agent-trust-engine.ts is a pure function with no I/O. It takes the delegate's current score, up to 100 recent activity rows, the current and original scope lists, an optional AI delta and the delegate's revocation floor, and returns a new score, the signals it found, a narrowing action and the scope list to write. The calling edge function does every read and write.

When it runs. There is no scheduled job and no idle decay. Evaluation happens in exactly three places:

  • shield-ingest receives a soft signal for the tenant and fires evaluate_tenant, debounced to one call per tenant and signal type per 30 seconds.
  • shield-intelligence-worker finishes an analysis whose agent_trust_delta exceeds 0.05 in absolute value and fires evaluate_tenant with that delta.
  • An operator-triggered single evaluate, which is service-role only.

A delegate that is never touched by one of these keeps the score it was created with, however long it sits idle.

Signals. Seven types, each weighted by how far past its threshold the behavior is. The watermark is trust_updated_at: every detector except extended_inactivity counts only rows newer than it, so evidence judged by one evaluation is not charged again by the next (since 2026-09-18).

SignalWindowTriggerMaximum weight
velocity_spike5 minutesMore than 50 activity rows0.30
error_burst2 minutesMore than 10 rows whose result is not success0.25
scope_violation24 hours, since the watermarkAny row whose result is scope_violation0.35
ip_rotation10 minutesMore than 5 distinct ip_hash values0.20
extended_inactivity24 hoursA gap over 24 hours followed by more than 10 actions in 5 minutes0.15 (flat)
ai_behavioralPer evaluationA non-zero ai_trust_delta0.30
normal_usagePer evaluationAt least one activity row newer than the watermark and no negative signal+0.05

Severity scaling. A negative weight is its maximum multiplied by how far past the threshold the count is, capped at 1. Sixty actions in five minutes is ten over the threshold of 50, so the velocity weight is 0.30 multiplied by 10/50, which is 0.06. A hundred or more actions reaches the full 0.30. scope_violation scales on violations divided by 5.

Score arithmetic. Each negative weight is subtracted from the current score, normal_usage adds 0.05, and the AI contribution is the raw delta multiplied by 0.3 (so it can raise the score as well as lower it). The result is clamped to the range 0.000 to 1.000 and rounded to three decimals.

Hard signals never reach the engine. evaluate_tenant called with a hard signal type answers { "status": "hard_signal_bypass" } and changes nothing, because shield-ingest has already revoked every delegate in the tenant inline. See Cascade classification.

Graduated scope narrowing

The new score maps to one of four actions. The first threshold is per delegate; the rest are fixed in the engine.

ScoreActionScopes written
Below auto_revoke_below (default 0.200)recommend_revocationEmpty, and is_active is set to false
At least 0.700noneUnchanged, or original_scopes restored if the delegate was narrowed
At least 0.400narrow_to_readOnly ingest:read, events:read, playbook:read, intelligence:read and verify that the delegate already had, with verify always added
Otherwisenarrow_to_verifyExactly ["verify"]

auto_revoke_below is set at creation (the wizard allows up to 0.50) and is checked before the ladder, so a delegate with a floor of 0.45 is deactivated at 0.44 rather than narrowed to read-only. A value outside the open interval 0 to 1 falls back to 0.200.

Scope preservation and restoration. The first time a delegate is narrowed (that is, while scope_narrowed_at is still null), its current scopes are copied to original_scopes, an empty list included, because an empty list means unrestricted. When the score climbs back to 0.700 or above and a narrowing is on record, the original list is written back automatically, empty or not, scope_narrowed_at is cleared, and the reason reads Trust score recovered—restoring original scopes. Until 2026-09-17 an unrestricted delegate could not be restored: its original list was empty, the restore rule compared lengths, and a second narrowing overwrote the empty original with the narrowed list.

Manual restore. Dashboard > Agents, expand a narrowed delegate, press Restore. That routes through shield-admin-mutations action restore_agent_scopes, which reads original_scopes from the server rather than trusting the browser, writes them back, sets the trust score to 1.0, clears the narrowing reason and writes a delegate.trust_restored audit row. A delegate with no recorded original scopes answers 400 and nothing changes.

Revocation is not reversible. recommend_revocation sets is_active to false. Restore does not bring a revoked delegate back; issue a new delegate and a new token.

A2A horizontal trust protocol

Two delegates in the same tenant can negotiate a shared trust level and transaction limits through shield-a2a-handshake, implementing the protocol in U.S. patent application 19/561,964.

In order: initiate creates a pending negotiation between two of your delegates, attest runs five blocking checks on the humans behind them (both delegates active, both delegators hold at least one passkey, no hard signal for either in the last five minutes), and negotiate computes a Combined Trust Coefficient from the two trust scores and writes a transaction ceiling and a per-minute rate against the negotiation row. status, lookup and renew read and extend it; invalidate is a service-to-service action.

Every action except invalidate takes the dashboard session JWT of a tenant member, so this is a call your backend makes on behalf of your own agents rather than something an agent makes with its own token.

The full request and response shape for each of the seven actions, the coefficient formula, the limit tiers and the cache rules are in A2A trust negotiation.

What to expect in the dashboard. Dashboard > Agents > A2A trust negotiations lists the most recent 50 negotiations for the tenant, each with its two agent labels, its status, its trust state, its coefficient gauge and its transaction limits.

Security hardening

Replay protection. initiate, attest, negotiate and renew each require a fresh nonce in the body. The nonce is hashed with SHA-256 and inserted into shield_a2a_nonces, so a reused value collides on the unique constraint and answers 409 with code: "REPLAY_DETECTED". A timestamp field is optional; when present it must fall within 300 seconds of server time. Stored nonce hashes carry a five-minute expiry.

mTLS certificate pinning. initiate, attest and negotiate each look up pinned certificates for both delegates in shield_agent_certificates and compare them with the fingerprint in X-Client-Cert-Fingerprint, X-SSL-Client-SHA256 or X-Agent-Cert-Fingerprint. A delegate with no pinned certificate passes, so with an empty table every handshake passes this check. There is no API action or dashboard control that writes a pin today, so in practice mTLS is inert; the pinning and revocation functions exist in _shared/mtls-agent-identity.ts with no caller.

Attestation proofs. A successful attestation signs a payload with HMAC-SHA-256 and ML-DSA-65 and stores the first 44 characters of each result in initiator_proof_hash and responder_proof_hash, with the algorithm, a truncated key fingerprint and a quantum nonce in metadata. These are truncated prefixes kept as evidence that the step ran; they cannot be verified as signatures later. If the post-quantum module is unavailable the handshake falls back to halves of a SHA-256 digest and records proof_scheme: "sha256_fallback".

Tenant isolation. Every delegate and negotiation query is filtered by tenant_id, and verify requires the tenant id alongside the token. A delegate in one tenant is invisible to another.

Admin-only mutations. create and revoke require the admin role in shield_tenant_members. list needs only membership. verify is public by design and rate limited, because an agent has no user session.

Token handling. Only the SHA-256 digest of the agent token is stored. The plaintext is returned once by create and shown once by the wizard.

Audit trail. Single-delegate evaluation writes delegate.trust_degraded, delegate.trust_restored, delegate.scope_narrowed or delegate.trust_revoked when the score moves by more than 0.05 or scopes change. The batch path that production actually uses writes one delegate.batch_trust_evaluated row per run with counts instead of per-delegate rows, so the per-delegate timeline in the Agents tab fills in only from manual restores and single evaluations. A2A actions write a2a.negotiation_initiated, a2a.attestation_passed, a2a.attestation_failed, a2a.negotiation_completed, a2a.trust_cache_renewed and a2a.hard_signal_invalidation.

Limits and quotas

There are no plan tiers for agent delegation. No function in this area calls a plan check, the Agents tab is not gated, and there is no quota on the number of delegates a tenant may hold.

LimitValueWhere
Delegates evaluated in one batch200, with _note in the response when the cap is hitshield-agent-trust evaluate_tenant
Activity rows read per delegate100 most recentTrust engine input
Concurrent activity queries20Batch evaluation
verify requests30 per 60 seconds per client IP, in-memory per isolateshield-agent-delegate
Authenticated delegate actions60 per 60 seconds per client IP, in-memory per isolateshield-agent-delegate
Scopes per delegate20 items, 50 characters eachcreate
Label length200 characterscreate
Delegation lifetime8760 hours, or omit expires_in_hours for nonecreate
Negotiation TTL10 to 3600 seconds, default 60shield-a2a-handshake
Request bodySmall-JSON cap, 413 above itAll three functions

The two IP limits are best-effort: they are counted in the memory of whichever isolate serves the request, so a burst spread across isolates can exceed them briefly. See Rate limits and quotas.

Errors

`shield-agent-delegate`

StatusBody errorCause
400Invalid JSON bodyBody did not parse
413Payload too largeBody above the small-JSON cap
400Unknown actionaction is not create, list, revoke or verify
401Missing authorization headerNo Authorization: Bearer on an authenticated action
401Invalid auth tokenThe session token did not resolve to a user
403Only admins can create agent delegatesMember role calling create
403Only admins can revoke agent delegatesMember role calling revoke
404No tenant foundThe caller has no membership, or none in the requested tenant
400Invalid tenant_id formattenant_id present but not a UUID
400scopes must be an array of strings (max 20 items, 50 chars each)Scope list rejected
400auto_revoke_below must be below initial_trust, otherwise the delegate is revoked on its first assessmentTrust bounds cross
400Missing agent_tokenverify without a token
403Unknown or revoked agentToken digest matches no active delegate in that tenant
403Agent delegation has expiredexpires_at is in the past
403Agent not authorized for scope: and the scope nameRequested scope is not in the delegate's current list
404Delegate not found for this tenantrevoke with an id from another tenant
429Too many requests. Please retry after 60 seconds.Above the per-IP limit
403Direct access denied. Use https://api.passkeybridge.io/v1/{function}.Called at the Supabase functions host instead of the public host

`shield-agent-trust` answers 401 to every caller that is not holding the service-role key, so from outside the platform that is the only status you can observe.

Related from the blog