Scopes & Permissions
The fourteen mintable API key scopes, what each function actually checks, the admin super-scope, and the separate scope systems used by agent delegates and A2A negotiations.
Overview
Every API key carries an array of scopes that decides which endpoints it can reach. Scopes are chosen when the key is minted, in Dashboard > API keys > "Generate API key", and are stored on the key row as a text array. They cannot be edited afterwards; mint a new key instead.
Enforcement lives in one place. The shared authentication module compares the endpoint's required scope against the key's array, treating admin as satisfying everything, and refuses with a single shape:
HTTP/1.1 403 Forbidden
x-pb-reason: missing-scope
{ "error": "API key missing 'vc_issue' scope" }When several scopes would do, the message joins them: API key missing 'metrics' or 'events' scope.
Dashboard session tokens are not scoped by this array. They are authorised by is_tenant_admin and are deemed to hold admin, so they pass every scope check. Scopes bound what a long-lived headless credential can do; a tenant admin acting in the console is the principal who mints those credentials in the first place.
Agent delegates have a separate scope system with its own vocabulary and its own narrowing engine. It has nothing to do with API key scopes and is covered further down this page.
Who can use this: every tenant on every plan. Some scoped endpoints are additionally plan-gated, and the enforcement table below names which.
Scope reference
These are the exact strings stored on the key row and offered by the key wizard. Fourteen can be minted; admin cannot.
| Scope | Label | Description | Example endpoints |
|---|---|---|---|
ingest | Signal Ingest | Submit signals, drive BLAST tunnels, call the predictive ladder | shield-ingest, shield-blast, shield-predict |
events | Events | Read processed events and metrics | shield-metrics (prometheus action) |
metrics | Metrics | Read tenant metrics without event access | shield-metrics (prometheus action) |
vc_issue | VC issue | Issue credentials and create OID4VCI offers | shield-vc-issue, shield-vc-oid4vci |
vc_verify | VC verify | Verify presentations and build OpenID4VP requests | shield-vc-present |
vc_revoke | VC lifecycle | Revoke, suspend, reinstate and rebuild the StatusList2021 bitstring | shield-vc-status (writes) |
spatial | Spatial Binding | Atomic Fingerprint capture and verification | shield-spatial-bind |
scim | SCIM | SCIM v2 user and group provisioning | shield-scim |
okta_hooks | Okta Hooks | Okta event hook consumer | shield-okta-hooks |
dpop | DPoP | DPoP proof-of-possession binding | shield-dpop |
supply_chain | Supply chain | Supply-chain attestation (Enterprise) | shield-supply-chain |
passkey_rp | Passkey relying party | Enroll and verify passkeys for your domain (Enterprise) | shield-passkey-rp |
passkey_rp_import | Passkey import | Bulk import existing passkey credentials (Enterprise) | shield-passkey-rp |
receipts | Entropy Provenance Receipts | Read signed provenance receipts for artifacts the platform signed (Enterprise) | shield-entropy-receipt |
admin | Admin (super-scope) | Satisfies every scope check | all scoped endpoints |
Retired names. Until 2026-09-15 the wizard also offered playbooks, credentials, agents and shadow. No edge function has ever checked them, so a key carrying one has exactly the access any active key of that tenant has. Existing keys keep working; the names can no longer be minted, and the credential endpoints check vc_issue, vc_verify and vc_revoke by name.
Two scopes reach one endpoint between them. events and metrics gate the Prometheus exposition on shield-metrics and nothing else. Either one satisfies the check; the JSON actions on that function refuse a key whatever it carries. Event history is read from Dashboard > Events. Prometheus export has the scrape configuration.
Scope enforcement by function
What each function actually checks, read from its source. A plan column entry means the function applies that gate itself, after authentication.
| Function | Auth lane | Required scope | Plan gate |
|---|---|---|---|
shield-ingest | key, or admin session with x-pb-test-mode | ingest (or admin) | none; Starter has a monthly volume cap |
shield-blast | key or admin session | ingest | Enterprise |
shield-predict | key or admin session | ingest for predict, admin for ladder_stats | Enterprise plus the ladder add-on |
shield-vc-issue | key, or admin session | vc_issue | none; needs credentials enabled |
shield-vc-oid4vci | key or admin session for offer | vc_issue; metadata, token and credential need no key | none |
shield-vc-present | key or session | vc_verify (or admin) | none |
shield-vc-status | key or admin session for writes | vc_revoke (or admin); reads are public | none |
shield-vc-verify | key, or admin session in test mode | none checked; any active key of the tenant | none |
shield-spatial-bind | key only | spatial (or admin) | Enterprise |
shield-dpop | key for bind, verify, nonce; session for introspect and cleanup | dpop | none |
shield-supply-chain | key or session | supply_chain | Enterprise |
shield-entropy-receipt | key | receipts | Enterprise |
shield-passkey-rp | key | passkey_rp; passkey_rp_import for import | Enterprise; sandbox keys refused |
shield-scim | key as Authorization: Bearer | scim literally; admin is not consulted here | none |
shield-okta-hooks | key as Authorization: Bearer | any of okta_hooks, ingest, admin | none |
shield-carrier-lookup | key or session | none checked; validates the key and the quota | none |
shield-sdk-attestation | key or session | none checked | none |
shield-cascade | key or session | admin, so in practice a session | Enterprise |
shield-metrics | session; key for prometheus only | metrics or events on the key lane | none |
shield-provenance-guard | session (console lane) | admin | console-only |
shield-breakglass | session (console lane) | breakglass | console-only |
Functions with no scope option. shield-cross-reference, shield-shadow-proxy and shield-cached-proof take a dashboard session token only and are Enterprise-gated; shield-agent-delegate (except the public verify action) and shield-a2a-handshake take a session token only; shield-agent-trust takes the service-role key only.
Where no scope is checked, the module still validates that the key is active and belongs to the named tenant, and enforces the per-tenant quota where the function asks for it. Any active key of that tenant can call such an endpoint whatever its scopes.
Signal ingest has one carve-out. A key whose scopes array is empty is accepted if the key was created before 2026-09-03, and the response then carries x-pb-scope-warning: legacy-unscoped. A scope-free key created at or after that cutoff is refused like any other key that lacks the scope, so the carve-out cannot be used to mint a permanently unscoped credential.
The admin super-scope
admin satisfies every scope check. The rule lives in one helper, which every key-authenticated function calls rather than re-deriving:
export function keyHasScope(
scopes: readonly string[],
required: string | readonly string[],
): boolean {
if (scopes.includes(ADMIN_SCOPE)) return true;
const wanted = typeof required === "string" ? [required] : required;
return wanted.some((s) => scopes.includes(s));
}The matching refusal is equally centralised, which is why the 403 body and the x-pb-reason: missing-scope header are identical across the platform:
export function missingScopeResponse(required: string | readonly string[]): Response {
const wanted = typeof required === "string" ? [required] : required;
const label = wanted.map((s) => `'${s}'`).join(" or ");
return errorResponse(`API key missing ${label} scope`, 403, {
headers: { "x-pb-reason": "missing-scope" },
});
}`admin` cannot be minted. It is absent from the provisionable set in shield-admin-mutations and from the dashboard picker, so no admin can create a headless credential carrying it. Requesting it answers 400 Unknown scope(s): admin. breakglass is console-only for the same reason.
A dashboard session holds it. A tenant admin authenticated interactively is deemed to hold admin for the tenant they just passed is_tenant_admin on. That is the supervised path the minting barrier exists to funnel people into, and it is bounded to one tenant.
One endpoint ignores it. shield-scim checks for the literal string scim on the key and does not consult admin, so an admin-scoped key is refused there. That is the only exception on the platform.
The practical consequence: an endpoint whose required scope is admin, such as shield-cascade or the console-only provenance guard, is reachable from the dashboard and is not reachable with any key a tenant can create.
Least-privilege patterns
Mint one key per integration with only the scopes that integration calls. A key limited this way cannot be repurposed by whoever finds it.
| Use case | Label | Scopes | Why |
|---|---|---|---|
| Signal ingestion pipeline | prod-ingest | ingest | Submitting signals is the whole job. Event history is read from the dashboard, so no read scope is needed. |
| Credential issuer | prod-vc-issuer | vc_issue, vc_revoke | Issue and manage lifecycle. Status reads need no key at all. |
| Credential verifier | prod-vc-verifier | vc_verify | Verification only; it cannot issue. |
| SCIM provisioning | prod-scim | scim | Directory sync only, and the literal scope is required here. |
| Okta event hooks | prod-okta | okta_hooks | Hook consumption only. |
| Hosted passkeys | prod-passkey-rp | passkey_rp | Ceremonies only. Add passkey_rp_import to the migration key alone, and revoke it when the migration finishes. |
| Device attestation SDK | prod-spatial | spatial | Fingerprint enrolment and verification only. |
| Token binding | prod-dpop | dpop | Binding and verification only. |
Anti-patterns.
- One key with every scope. The wizard defaults to
ingestandeventsfor a reason. A key that can do everything turns any leak into a full compromise. - Sharing a key across services. You cannot revoke one consumer without breaking the others, so nobody revokes anything.
- Reaching for `admin`. It cannot be minted, and wanting it usually means the endpoint is a console function with no public route.
- Using a sandbox key in production. It is capped at 1,000 signals per rolling 30 days, its events are flagged as tests and excluded from your dashboards, and its outbound actions are simulated rather than sent.
- Keeping an import scope around.
passkey_rp_importexists for a migration. Revoke that key when the migration is done.
Rotating a scope set means minting a new key, moving traffic, and revoking the old one. Scopes on an existing key cannot be edited, and the browser has no write grant on the key table.
Agent scope narrowing
Agent delegates carry their own scope array, unrelated to API key scopes. The strings are yours: whatever you pass to create is stored, and the public verify action checks a requested scope against that array.
Each delegate row keeps the current scopes, the original_scopes recorded at delegation time, scope_narrowed_at, a narrowing_reason and the signal that caused it.
The trust engine narrows automatically. It scores recent behaviour and applies graduated reduction. The engine is a pure function; the database work happens in the calling function.
| Trust score | Action | Scopes retained |
|---|---|---|
| at or above 0.7 | none | full original scopes, restored if they had been narrowed |
| 0.4 to 0.699 | narrow_to_read | the read subset of the original scopes: ingest:read, events:read, playbook:read, intelligence:read, verify, plus verify if it was absent |
| 0.2 to 0.399 | narrow_to_verify | verify only |
| below the delegate's floor | recommend_revocation | empty array, and the delegate is deactivated |
The revocation floor is per delegate. auto_revoke_below defaults to 0.2 and is set at creation time; it must be below initial_trust, otherwise the delegate would be revoked on its first assessment and the create call refuses it. A value outside the range falls back to 0.2. The floor is checked before the narrowing ladder, because an operator may set it above 0.2.
Signals that reduce trust.
| Signal | Maximum weight | Detection |
|---|---|---|
scope_violation | 0.35 | actions attempted outside the delegated scopes |
velocity_spike | 0.30 | more than 50 actions in a 5-minute window |
ai_behavioral | 0.30 | the intelligence worker's delta, scaled |
error_burst | 0.25 | more than 10 failed actions in a 2-minute window |
ip_rotation | 0.20 | more than 5 distinct IP digests in a 10-minute window |
extended_inactivity | 0.15 | a burst after more than 24 hours of silence |
Recovery is slow by design. With no negative signal detected and at least one activity row newer than the watermark, a normal_usage signal adds 0.05 per evaluation; an evaluation with nothing new to read leaves the score where it is. A delegate must demonstrate sustained stable behaviour to climb back. When the score passes 0.7 the engine compares the current scopes against the originals and restores them if they are smaller.
Hard signals bypass this entirely. A hard signal revokes every active delegate for the tenant inline during ingest and sets its trust score to zero. The trust engine is the graduated path for soft signals only.
A2A negotiation scopes
An agent-to-agent negotiation carries an allowed_scopes array of its own, derived rather than supplied.
The intersection is the ceiling. negotiate computes the intersection of the two delegates' scope arrays, so a negotiation can never grant more than the less privileged participant holds.
The trust state narrows it further. The combined trust coefficient is the geometric mean of the two trust scores, minus a network jitter penalty (up to 0.20 once jitter passes 150 ms), plus 0.05 when a spatial fingerprint is bound, plus up to 0.03 for a fresh SIM signal. That value maps to a state, and the state filters the intersection:
| Coefficient | State | Scopes allowed |
|---|---|---|
| at or above 0.70 | full | the whole intersection |
| 0.40 to 0.699 | read_only | intersection members containing read, plus verify |
| 0.20 to 0.399 | verify_only | verify only |
| below 0.20 | auto_revoked | none; the negotiation is revoked |
Transaction limits come from the same value, from a fixed tier table: 100,000 and 60 per minute at 0.90 and above, 25,000 and 30 at 0.70, 5,000 and 10 at 0.40, 500 and 3 at 0.20, and zero below that. They apply whatever the scopes say.
Re-attestation. status reports cache_valid. A negotiation must re-attest when its lifetime expires, when a hard signal arrives, or when the live geometric mean has drifted more than 0.10 from the cached coefficient. renew refuses a drifted negotiation with renewal_denied and action_required: "re-attest".
Revocation. A negotiation whose delegate is deactivated is revoked on its next renew. A hard signal revokes every active negotiation for the tenant inline during ingest, and the service-only invalidate action does the same on demand: status revoked, trust state auto_revoked, coefficient and limits zeroed and allowed_scopes emptied.
Session tokens and API keys compared
| Dimension | Dashboard session | API key |
|---|---|---|
| Source | Supabase session from signing in | minted server-side by provision_api_key |
| Header | Authorization: Bearer <token> | x-pb-api-key: <raw key>, or Authorization: Bearer <key> for SCIM and Okta hooks |
| Authorization check | is_tenant_admin(user, tenant) | SHA-256 hash match on an active row for the tenant, then the scope check |
| Scope enforcement | deemed to hold admin, so every check passes | the key's own array, with admin satisfying everything |
| Tenant binding | the tenant the admin check named | the tenant that owns the key row |
| Rate limiting | same per-IP layers and per-tenant quota | same per-IP layers and per-tenant quota |
| Revocation | sign out, or session expiry | set is_active false through the revoke control |
| Audit actor | actor_type: "user" with the user id | actor_type: "api_key" |
Test mode. x-pb-test-mode: true forces the session lane on endpoints that accept both, even when a key is present. Signal ingest uses it for the dashboard's test signal and flags the resulting event test_mode, which excludes it from metering and from the live filter in Dashboard > Events.
Legacy header. x-shield-api-key is accepted wherever x-pb-api-key is, and is checked second. New integrations should use x-pb-api-key.
Neither lane is a superset of the other. A key reaches endpoints the dashboard never calls, and several endpoints (cross-reference, shadow proxy, cached proofs, the delegate lifecycle, A2A, cascade) have no key lane at all.
Key management lifecycle
Creation. Dashboard > API keys > "Generate API key". The server generates the key as a prefix plus two UUIDs, SHA-256 hashes it, stores the hash with the first 16 characters as the display prefix, and returns the raw key exactly once. The browser holds no write grant on the key table, so this path is the only one.
The scopes are validated against the provisionable set: an unknown name answers 400 Unknown scope(s): <names>. The label is 1 to 120 characters, and at most 20 scopes may be requested.
Fields on the key row.
| Column | Type | Description |
|---|---|---|
id | uuid | primary key |
tenant_id | uuid | owning tenant |
key_hash | text | SHA-256 of the raw key |
key_prefix | text | first 16 characters, shown in the dashboard |
label | text | your label for the key, nullable |
name | text | legacy column, defaults to Default Key |
scopes | text[] | the scope array |
is_sandbox | boolean | true for pb_test_ keys; set server-side so it always agrees with the prefix |
is_active | boolean | false once revoked |
last_used_at | timestamptz | updated after a successful authentication, without blocking the response |
created_by | uuid | the admin who minted it |
created_at | timestamptz | creation time |
Rotation is mint, migrate, revoke. There is no in-place rotation and no automatic rotation for API keys. Watch last_used_at on the old key to confirm traffic has moved before revoking.
Revocation goes through the revoke_key action, which verifies your admin role and sets is_active false. It takes effect on the next request. A key id that is not this tenant's answers 404 API key not found for this tenant. There is no hard delete: the row stays for the audit trail, and the hash can never match again.
Audit. Provisioning writes api_key.provisioned with the display prefix, the scopes, the sandbox flag and the label. Revocation writes api_key.revoke with the key id. Neither records the key or its hash. Both appear in Dashboard > Audit log.
Related from the blog
- A2A Trust Negotiation: How Two AI Agents Prove Identity to Each Other Without a Humansecurity · 15 min read
- Anatomy of an A2A Handshake: How Two AI Agents Establish Trust Without a Shared Secretsecurity · 13 min read
- The Identity Stack for Autonomous Commerce: When Your AI Agent Needs a Credit Linesecurity · 14 min read