Skip to content

Agent passports: prove which agent is calling

When your agent calls someone else’s API or MCP server, that service sees an API key or an OAuth token. It does not see which agent is calling, who runs it, or whether a person is behind the call.

An agent passport says those three things, signed by Shield360:

  • the agent: its id, name, purpose, risk tier, run context, its owner’s email domain and when it was enrolled and last reviewed, from its agent authorization profile;
  • the operator: your organisation, with its verified email domain;
  • the person, when the agent acts for one: an id that is stable for that person at that receiver, their email domain, and how strongly Shield360 stands behind the claim (its assurance). Never their name, user id or email address.

The agent sends the passport in the Shield360-Agent header. A passport does not grant access. The receiver’s own API keys, tokens and permissions still decide what the call may do. The passport tells the receiver who is asking, so it can apply its own policy and keep a record.

Passports are part of the Security module and use agent authorization: only an enrolled agent can get one, and every request is decided and logged.

Before calling a receiver, the agent asks Shield360 for a passport with its own agent token:

Terminal window
curl -X POST "$SHIELD360_URL/api/agent-passports" \
-H "Authorization: Bearer $SHIELD360_AGENT_TOKEN" \
-H "X-Shield360-Environment: production" \
-H "Content-Type: application/json" \
-d '{ "audience": "api.servflo.ai", "scopes": ["jobs.read"], "onBehalfOf": "<Shield360 user id>" }'
FieldMeaning
audienceRequired. The one receiver the passport is for: a host name (api.servflo.ai) or an https origin (https://api.servflo.ai). No paths, no wildcards.
scopesOptional. Up to 32 scopes to present, each 1 to 64 characters from A-Z a-z 0-9 _ . : -. The receiver defines what they mean, such as jobs.read or invoices.write.
onBehalfOfOptional. The Shield360 user id of the person the agent acts for. Without it, the X-Shield360-On-Behalf-Of header is used, as at the other enforcement points.

The request is decided like a request to reach the receiver’s host:

  • the agent’s kill switch and token must be live;
  • the run context (X-Shield360-Environment) must be one the profile allows;
  • the receiver’s host must be on the agent’s Destinations list, or covered by an approved elevation;
  • when the agent acts for a person, that person’s role must let agents act for them. Scopes ending in .read need agent_delegation.read; .write needs agent_delegation.write; .delete needs agent_delegation.delete; any other scope needs agent_delegation.write.

Every answer is in the agent’s decision log under the Agent passports enforcement point.

StatusBodyWhat to do
201{ passport, passportId, expiresAt, decisionId }Send passport in the Shield360-Agent header. It is valid for five minutes; ask for a new one after that.
202{ code: "needs_approval", reason, elevationId? }The host is not on the list, but the agent may ask for it. Call POST /api/agent-authorization/request-access with kind: "destination", then ask again once it is approved.
403{ code, message, decisionId }Refused. code is the reason, such as destination_not_approved, kill_switch, run_context or delegator_not_permitted.
400{ err }The JSON or a field is malformed.
401{ code: "agent_token_invalid" }Send the agent token as a Bearer credential.
429{ code: "rate_limited" }At most 60 requests a minute per agent. Wait for Retry-After seconds.

Then call the receiver as usual, adding the header:

Terminal window
curl "https://api.servflo.ai/v1/jobs" \
-H "Authorization: Bearer $SERVFLO_API_KEY" \
-H "Shield360-Agent: $PASSPORT"

A passport is for one receiver and should be used once. Ask for a new one for each call, or at least for each receiver.

A passport is a compact JWS (RFC 7515) signed with EdDSA (Ed25519). Its header is { "alg": "EdDSA", "typ": "shield360-agent+jwt", "kid": "<key id>" }. The claims:

ClaimMeaning
issThe Shield360 that issued it, such as https://app.shield360.ai. Its keys are at ${iss}/api/agent-passports/keys.
subagent:<agent id>.
audThe receiver it was issued for: a host name or an https origin.
iat, expIssued and expiry times in seconds. At most 300 seconds apart.
jtiThe passport’s unique id. Accept each one once.
agentWhat Shield360 knows about the agent, from its profile: id, name and purpose (when set); status (enrolled or not_enrolled); environment, the run context it asked from, or null; risk, its risk tier (low, medium or high), or null; owner: { domain }, the email domain of the person accountable for it (never who they are), or null; reviewedAt, when its last access review was attested, or null; enrolledAt, when it was enrolled, or null. Times are ISO 8601.
operator{ id, name, domain }: the organisation that runs the agent, and an email domain it has proved it owns with a DNS record, or null if it has none.
actWhenever the agent names a person it acts for: { sub, domain, assurance, via?, mandate? }. sub is a pairwise id: the same person always has the same id at your service, but a different id at every other receiver, so ids cannot be matched across services. domain is the person’s email domain, or null. assurance is delegated, named or unverified (see below). A delegated claim also says via (mandate or oauth_consent) and, for a mandate, mandate: { id, expiresAt }.
scopeSpace-separated scopes the agent chose to present, when it presented any. Your own policy decides what they mean.
decision{ id, policyVersion }: the issuer’s decision log entry and the version of the agent’s policy it was decided under.

Assurance: how strongly the person is stood behind

Section titled “Assurance: how strongly the person is stood behind”
assuranceWhat it provesWhat it does not prove
delegatedThe person is a member of the operator’s organisation and, while signed in to Shield360, gave this agent a standing grant that is still live: an agent mandate (via: "mandate", with its id and expiry) or an OAuth consent at the MCP gateway (via: "oauth_consent"). Their role lets agents act for them.That the person asked for this particular call, or is present now.
namedThe person is a member of the operator’s organisation and their role lets agents act for them with the scopes presented.That the person agreed to anything: the agent’s token holder named them.
unverifiedOnly that the agent said it acts for someone. They are not a member of the operator’s organisation, so Shield360 knows nothing about them. sub is computed from a one-way reference of the name the agent gave, and domain is that name’s domain when it is an email address.Anything about who the person is.

Treat delegated as strongest and unverified as weakest. An agent whose profile requires a known person is refused a passport for someone who is not a member.

Use the Shield360 SDK: shield360/agents for TypeScript or shield360.agents for Python. It runs every step below, applies your policy, and can report results to the Inbound agents page of your own Shield360 project. To verify by hand, run these steps in order. Each failure has a reason code.

  1. Parse. Three base64url parts; header typ is shield360-agent+jwt (malformed); alg is EdDSA and nothing else (unsupported_alg).
  2. Issuer. iss is one you trust, by default https://app.shield360.ai (untrusted_issuer).
  3. Key and signature. GET ${iss}/api/agent-passports/keys?kid=<kid> returns { "keys": [<Ed25519 JWK>] }. Cache it for an hour; an unknown key id is 404 (unknown_key). Check the signature (bad_signature).
  4. Time. exp is later than now minus 60 seconds (expired), iat is no later than now plus 60 seconds (not_yet_valid), and exp - iat is at most 300 (lifetime_too_long).
  5. Audience. aud is your host or origin (wrong_audience). Hosts compare case-insensitively, and an origin matches its own host.
  6. Replay. You have not seen this jti before. Remember it until exp plus 60 seconds (replayed).
  7. Online check (optional). For sensitive operations, ask the issuer:
Terminal window
curl -X POST "https://app.shield360.ai/api/agent-passports/verify" \
-H "Content-Type: application/json" \
-d '{ "passport": "<the Shield360-Agent header>", "audience": "api.servflo.ai" }'

It runs steps 1 to 5 and then checks what only the issuer knows: that it issued this passport to this agent for this receiver (unknown_passport), and that the agent’s token has not been revoked and its kill switch has not been pulled since (revoked). It does not track replay, so asking twice gives the same answer.

The answer is { "valid": false, "reason": "<code>" }, or { "valid": true } with the person’s current assurance when the passport has act. The assurance is the same as in the passport, or weaker when something behind it has gone since; it is never stronger. A weaker answer is still valid and says why:

AnswerWhy
{ "valid": true, "assurance": "named", "reason": "mandate_revoked" }The mandate behind a delegated passport was revoked or has expired.
{ "valid": true, "assurance": "named", "reason": "consent_revoked" }The OAuth consent behind a delegated passport was revoked or has lapsed.
{ "valid": true, "assurance": "unverified", "reason": "delegator_removed" }The person is no longer a member of the operator’s organisation.

Neither public route needs an account. Each answers about one key or one passport and lists nothing. Both are rate limited per caller (300 key requests and 120 verifications a minute). The signing key is the issuing environment’s attestation key, which also signs agent mandate proofs. A rotated-out key stays published, so passports it signed still verify until they expire.

iss is your NEXTAUTH_URL. Receivers must list it as a trusted issuer.

The pairwise person id is keyed with SHIELD360_AGENT_PASSPORT_SECRET. When it is not set, Shield360 derives a key from SHIELD360_VAULT_ENCRYPTION_KEY (or NEXTAUTH_SECRET). Changing either changes every pairwise id that receivers have seen. See Configuration.

Records of issued passports are kept until an hour after they expire, for the online check. The daily job then deletes them. The decisions stay in the decision log.

  • It does not grant access to the receiver. The receiver’s own credentials and permissions decide that.
  • It does not identify the person to the receiver. The receiver sees a pairwise id and an email domain only.
  • It does not stop a stolen passport from being used within its five minutes. A receiver that accepts each jti once, and checks aud, limits that to one call at one service.