How to give a chain of AI agents verifiable identity — so that every token says who acted on whose behalf, and the one place where that rule is deliberately broken is governed by policy rather than by a hard-coded shortcut.
Reference implementation on PingFederate (identity plane) and PingAuthorize (authorization plane), as config-as-code. Everything here is the real configuration from a working system, including the parts that didn't work the way the design said they would.
1. Delegation, not impersonation. Every agent gets its token by exchanging the token it was
handed (RFC 8693). The human stays as sub; each
hop wraps the calling agent around the incoming act chain. The token records the whole delegation
path, so "which agent did this, for which principal?" is always answerable.
{
"sub": "alice",
"act": {
"sub": "urn:agent:northwind-payments:v1",
"act": { "sub": "urn:agent:northwind-concierge:v1" }
}
}2. The governed flatten. For one downstream audience — an internal event sink — the chain is
deliberately collapsed: the root actor (the innermost act.sub) is promoted to sub, and
act is dropped. The agent acts as itself, because the true principal of "a payment event was
emitted" is the orchestration platform, not Alice. PingAuthorize governs the flip, so it can only
happen for that audience, and only for an authorised agent.
{
"sub": "urn:agent:northwind-concierge:v1",
"aud": "https://events.northwind.example"
}That second idea is the interesting one, and the dangerous one. It is exactly the shape of an impersonation bug — which is why it is fenced by an audience, structurally prevented from carrying a chain, permitted by policy, and enforced at both ends.
| Document | What it covers |
|---|---|
Token exchange and the nested act chain |
Impersonation vs delegation; how act nests and which end is the root; the three PingFederate objects; why the derived-act OGNL doesn't work and what ships instead; secretless client auth; the gotchas. |
| The flatten function | Why flatten at all; what it means precisely; how the audience selects a chainless token type; the fragile string-walk vs. the policy-governed design; how it's contained. |
| Policy model | The PingAuthorize TokenExchange domain, the flatten-governance policy, the audience-restriction rule — and the one-character default that stops an additive policy from denying the entire estate. |
| Config-as-code workflow | Apply → export → commit → deploy, against an ephemeral identity provider. |
| Demo runbook | Spin the whole thing up and prove it with curl — complete verified config inventory, policy pseudo-code, real wire captures (PF 13.1.1 + PAP 11.1, 2026-07-23), troubleshooting index. |
| Exchanges on the wire | The two token-exchange requests, side by side, with the resulting tokens and the errors you should expect. |
| What the event sink enforces | The receiving end — why it rejects a delegated token. |
| Path | |
|---|---|
pingfederate/terraform/token-exchange.tf |
Subject-token processor, token-exchange processor policy, per-agent access-token mappings that mint the nested act (production reference: attestation client auth). |
pingfederate/terraform/flatten.tf |
The audience-scoped events ATM (no act in its contract) + the flatten mapping. |
pingfederate/terraform/demo/ |
Self-contained, verified demo config — applies to a stock PF 13.x and mints the whole chain (see the runbook). |
pingauthorize/policy/ |
The TokenExchange domain, flatten-governance policy, audience-restriction rule — including the verified snapshot + deployment package (token-exchange-flatten.*, importable into any PAP / embedded PDP). |
pingauthorize/dsconfig/ |
Embedded-PDP runtime config. |
demo/ |
Runnable demo UI + mock event sink & bank API — every button executes the exact curl it shows; demo.sh is the headless version. |
flowchart TD
A[Alice — OIDC login<br/>sub=alice, no act] -->|exchange| C[Concierge agent<br/>sub=alice, act={concierge}]
C -->|exchange| P[Payments agent<br/>sub=alice, act={payments, act={concierge}}]
P -->|delegated token| BANK[Bank API<br/>authorise on principal + actor chain]
P -->|"exchange, resource=events"| F[FLATTENED token<br/>sub=concierge, aud=events, NO act]
F --> ES[Event sink<br/>requires: no act, sub=agent, aud=events]
F -.->|replayed at the bank| X[DENY — invalid_audience]
PAZ[[PingAuthorize PDP]] -.->|governs the flip| F
PAZ -.->|governs the call| BANK
style F fill:#3b2f00,stroke:#c79a00,color:#fff
style X fill:#4a1414,stroke:#c74a4a,color:#fff
The audience is the switch. The flatten reuses the same token endpoint, the same processor policy,
and the same subject token as a normal hop. The only meaningful difference is the resource
parameter — which selects an access-token manager whose contract has no act attribute at all.
The chain isn't stripped by a rule you could bypass; it is structurally unrepresentable in that
token type.
Propose vs. authorise are different privileges. Computing the root actor is trivial and needs no
privilege (the agent holds the token; it can decode its own act). Authorising the promotion of an
actor to a subject is the privileged step. So the agent proposes, and the PDP governs. The
alternative — extracting the root actor by string-walking the act JSON in an OGNL expression — is
brittle, and worse, it fails open to "". A security decision does not belong in a string
function.
Under a deny-biased combining algorithm, "absent" must be a value, not an error. The flatten
policy's requestedAudience attribute has defaultValue = "". Without it, every ordinary banking
request — which carries no audience — leaves the attribute unresolved → INDETERMINATE → and under
DenyOverrides that is a DENY. Adding one additive policy would have denied the whole estate,
with a failure mode that looks nothing like its cause.
Fence a flatten at both ends. The event sink rejects any token that still carries an act, or
whose sub is a human. The bank API denies any token minted for another audience. Each token is
usable at exactly one audience and useless at the other — so an upstream bug can't quietly become
downstream over-sharing.
Ship the constraint, don't hide it. The design called for act to be derived from the inbound
chain. PingFederate 13.x rejects OGNL referencing act on an access-token mapping
(ognl_expression_invalid_attribute), so what ships is a TEXT literal encoding a fixed topology —
correct here, but it does not generalise to arbitrary depth. That constraint is documented rather than
smoothed over, because a hard-coded chain that looks derived is precisely the thing that survives
into production and then silently mislabels an actor.
RFC 8693 (Token Exchange, act) ·
RFC 8707 (Resource Indicators) ·
RFC 9068 (JWT access tokens) ·
RFC 9449 (DPoP) ·
RFC 9396 (Rich Authorization Requests) ·
OAuth 2.0 Attestation-Based Client Authentication
This is an explainer with a runnable proof. The docs describe the identity/authorization
configuration and the reasoning behind it, extracted from a working agentic-banking demo; the
demo/ directory and pingfederate/terraform/demo/
pingauthorize/policy/token-exchange-flatten.snapshotmake the whole token pipeline independently spinnable and verifiable with curl — see the demo runbook for the verified captures (PF 13.1.1 + PAP 11.1, 2026-07-23) and the deliberate demo deltas (client secrets in place of the attestation jar; a curl-able login stand-in). The identifiers (urn:agent:northwind-*,*.northwind.example) are illustrative.
The Terraform and the policy objects are real — including the workarounds, which are re-verified against live product versions in the runbook.