Spec: proof-of-possession for agent tokens
grep -c cnf core/agent_tokens.py returns 0. Shield’s own agent credential is a
bearer token: copy it and it works. core/dpop.py already implements everything
needed to fix that, and core/identity_resolution.py already uses it — on
somebody else’s tokens.
Table of contents
- 1. Problem & outcome
- 2. Plane & latency contract
- 3. Data model
- 4. API / interface
- 5. Security & backward compatibility
- 6. Packaging & deploy
- 7. Failure modes & edge cases
- 8. Test plan (Definition of Done)
- 9. Deployment topologies — where PoP applies, and where it cannot
- 10. Task breakdown (one PR each)
1. Problem & outcome
mint_agent_token() (agent_tokens.py:153) emits
claims iss, aud, user_sub, agent_id, agent_instance_id, parent_agent_id,
tenant_id, build_hash, model_version, session_id, roles, iat, exp, jti, kid.
There is no cnf. verify_agent_token()
(agent_tokens.py:214) checks signature, expiry,
required claims, build allowlist and revocation — but there is nothing binding
the token to a key, so there is nothing to check possession against.
AgentIdentityMiddleware (agent_identity_middleware.py:47)
therefore grants full agent identity to anyone presenting the string.
The asymmetry is the striking part. core/dpop.py is a complete DPoP
implementation — jwk_thumbprint, verify_proof, cnf_jkt, claim_jti replay
protection, private-key-member rejection, an 8 KiB proof ceiling and a 30 s age
window. core/identity_resolution.py:88 uses all of it, via
SHIELD_TOKEN_BINDING, against workload-identity tokens from external IdPs.
Shield binds other people’s credentials and not its own.
The module docstring at agent_tokens.py:38 names the current mitigation: “≤15 minute lifetime caps the window of a stolen token.” A 15-minute window is a mitigation, not a control.
Outcome. An agent token carries cnf: {"jkt": ...} bound to a keypair the
agent holds, and a request presenting that token must carry a DPoP proof signed
by the matching private key. Observable success condition: a token copied
verbatim to a second process, without the private key, is refused with 401.
Non-goals
- Not RFC 9421 HTTP Message Signatures (what AAuth specifies). DPoP achieves the same property, is already implemented here, and is already understood by the OAuth ecosystem. Adopting 9421 is an interop decision, not a security one, and belongs in a conformance spec if it happens at all.
- Not a change to
SHIELD_TOKEN_BINDING. That flag governs external IdP tokens and keeps its exact current meaning. §4 explains why these stay separate rather than merging. - Not capability-token binding.
core/capabilities.pyhas its own signer, audience and nonce namespace by design; binding caps is a follow-on with a different latency argument (cap/mintis hotter). - Not key management. Where the agent’s private key lives — process memory, file, TPM, KMS — is the deployer’s decision. Shield only ever sees the public JWK.
- Not mTLS-derived identity. The
mtls_identityfallback at agent_identity_middleware.py:30 is already channel-bound by a client certificate and does not need a second possession proof.
2. Plane & latency contract
Planes: both.
- Admin plane — minting.
routes_agent_authis mounted at admin_app.py:1109. - Data plane — verification, in
AgentIdentityMiddleware, on every request carryingX-Agent-Token.
Touches the guard path: YES, and this is the expensive item on the roadmap.
Verification adds, per request, when enabled:
| step | cost |
|---|---|
| parse + validate the proof JWT | ~10 µs |
| one signature verification (EdDSA / ES256 / RS256) | 50–250 µs, algorithm-dependent |
| JWK thumbprint + constant-time compare | ~5 µs |
claim_jti replay check |
one nonce-store round trip |
The signature is not the problem; the nonce store is. claim_jti is the same
call identity_resolution.verify_token_binding already makes at line 110 with a
60 s TTL, so the infrastructure and its failure behaviour are established rather
than new.
Latency budget: < 1 ms added at p99 on /guardrails/*, cap/mint and
tools/call, and 0 when disabled.
What keeps the default free:
- The whole path sits behind
agent_token_pop_mode(). Inoff— the default — the middleware reads one cached env value and does nothing else. No header read, no import, no crypto. core.dpopis imported lazily inside the branch, matching the existing lazy import at identity_resolution.py:98.- The check runs after
verify_agent_token()succeeds. An invalid token never pays for a proof verification, so this cannot be used to make rejection more expensive than acceptance.
If the p99 budget is exceeded in practice, the mitigation is a local LRU in
front of the nonce store keyed by jti, not relaxing the check. Out of scope
here; flagged so the option is on record.
3. Data model
New token claim
cnf {"jkt": "<base64url SHA-256 JWK thumbprint>"} omitted when unbound
Omitted rather than null when absent, so tokens minted without a key stay
byte-compatible with today — the same convention roles uses at
agent_tokens.py:190.
IdentityTuple gains cnf_jkt: str = "" and pop_verified: bool = False, so
the audit can distinguish three states that must never be conflated: a bound
token whose proof verified, a bound token in optional mode whose proof was
merely recorded, and an unbound legacy token.
Redis
No new keys. The replay store is core/dpop.claim_jti’s existing namespace,
already in production use for SHIELD_TOKEN_BINDING. Reusing it means one
eviction policy and one operational surface rather than two.
No key storage. Shield stores no public keys. The thumbprint lives in the signed token; the full JWK arrives inside each proof header and is checked against the thumbprint. This is the property that makes DPoP cheap to operate — there is no key registry to provision, rotate, or lose.
Tenant scoping
Inherited. The thumbprint is inside a token already bound to tenant_id and
verified by verify_agent_token() before any of this runs. Nothing new is
tenant-keyed, so there is no new isolation surface.
4. API / interface
Config
SHIELD_AGENT_TOKEN_POP = off | optional | required # default: off
SHIELD_AGENT_TOKEN_POP_ALLOW_UNBOUND = true | false # default: false
Mode semantics deliberately mirror SHIELD_TOKEN_BINDING
(identity_resolution.py:51) so operators learn
one model:
| mode | bound token, valid proof | bound token, bad/missing proof | unbound token |
|---|---|---|---|
off |
pass, not checked | pass, not checked | pass |
optional |
pass, recorded | pass, recorded as failed | pass |
required |
pass | 401 | 401, unless allow-unbound |
required, trusted-proxy peer |
pass | pass, recorded | pass |
The last row is the LiteLLM case and is not a weakening: a proof cannot exist on
that path for reasons given in §9. The peer must satisfy
trusted_proxy_only() and peer_is_trusted(request) — the same gate
strict_proxy uses — so possession is delegated to a hop that authenticated
itself with a shared secret. pop_verified stays false and role_source-style
provenance keeps the two cases distinguishable in the audit. A caller that is not
the trusted proxy gets no exemption.
SHIELD_AGENT_TOKEN_POP_ALLOW_UNBOUND is meaningful only in required, and
exists because the jump from “deny nothing” to “deny every legacy token” is
where outages live. It is the escape hatch the repo’s secure-by-default
invariant requires: enforce proofs on tokens that have them, while clients that
have not yet migrated keep working. Setting it in any other mode MUST log a
warning at startup and MUST NOT change behaviour.
cnf is minted whenever the caller supplies a key, independent of mode.
That separation is what makes the rollout ladder possible: clients can start
binding while enforcement is still off, and the operator advances the mode
only once the audit shows the fleet is bound.
Mint request
AgentTokenRequest gains:
agent_jwk: Optional[dict] = Field(
None, description="Agent's PUBLIC JWK. Its SHA-256 thumbprint is bound "
"into the token as cnf.jkt. Private key members are rejected.")
In mint_agent_token():
if agent_jwk is not None:
from core.dpop import jwk_thumbprint, _reject_private_key
_reject_private_key(agent_jwk) # raises if d/p/q/dp/dq/qi/k present
claims["cnf"] = {"jkt": jwk_thumbprint(agent_jwk)}
_reject_private_key (dpop.py:82) already exists and must be
called, not reimplemented. A client that posts its private JWK by mistake gets a
400 rather than having Shield helpfully accept it — and both mint sites (lines
271 and 956 of routes_agent_auth.py) need it.
Proof header
The agent-token proof travels in X-Agent-DPoP, not DPoP.
This matters. DPoP is already consumed by
identity_resolution.verify_token_binding for the workload-identity token. With
both bindings enabled and different keypairs, one header cannot satisfy two
thumbprints, and overloading it would make the two features silently
incompatible. A separate header lets them be enabled independently. When a
deployer uses the same keypair for both, sending an identical value in both
headers is valid and costs one extra verification.
Verification
In AgentIdentityMiddleware.dispatch, immediately after line 47:
identity = verify_agent_token(token)
mode = agent_token_pop_mode()
if mode != POP_OFF:
status, err = verify_agent_pop(request, identity)
identity = replace(identity, pop_verified=(status == POP_VERIFIED))
if mode == POP_REQUIRED and status != POP_VERIFIED:
if not (status == POP_UNBOUND and allow_unbound()):
return JSONResponse(401, {"error": "agent_pop_required", "detail": err})
with the helper in core/agent_tokens.py:
def verify_agent_pop(request, identity) -> tuple[str, str]:
"""(status, reason). Never raises: a PoP check that 500s on the guard path
is worse than one that reports failure and lets the caller decide."""
try:
from core import dpop
if not identity.cnf_jkt:
return POP_UNBOUND, ""
proof = request.headers.get("X-Agent-DPoP", "").strip()
if not proof:
return POP_FAILED, "bound agent token presented without a proof"
from core.proxy_trust import effective_request_uri
p = dpop.verify_proof(proof, expected_jkt=identity.cnf_jkt,
http_method=request.method,
http_uri=effective_request_uri(request))
if not dpop.claim_jti(p.jti, 60):
return POP_FAILED, "proof replayed"
return POP_VERIFIED, ""
except Exception as e:
return POP_FAILED, str(e)
Structure and never-raises posture copied from verify_token_binding
(identity_resolution.py:88) on purpose: two
functions doing the same job should look the same.
Shared htu canonicalization — do not skip this
verify_proof compares the proof’s htu against the URI Shield computes.
Behind a load balancer, str(request.url) is the internal URL, so a proof
signed over the public URL fails. identity_resolution._request_uri
(identity_resolution.py:62) already solves
this, reading X-Forwarded-Proto/Host only when trusted_proxy_only()
and peer_is_trusted() agree.
Promote it to core/proxy_trust.effective_request_uri(request) and have both
call sites use it. core/proxy_trust.py is already the module that owns proxy
trust and is already in the admin allowlist
(Dockerfile.admin:74). Leave _request_uri as a thin
delegating alias so nothing else moves.
Two independent implementations of htu canonicalization is how you get a feature
that works in tests and fails in every real deployment. It is also how you get a
security bug, since the unsafe version of this function trusts
X-Forwarded-Host unconditionally and lets a caller choose its own htu.
5. Security & backward compatibility
Default behaviour: unchanged. SHIELD_AGENT_TOKEN_POP defaults to off,
agent_jwk is optional, and a token minted without it has no cnf. Existing
clients need no change and see no difference.
The threat this closes. Today, an agent token in a log file, a crash dump, an error report, a proxy access log, or a compromised sidecar is a working credential for up to 15 minutes. After: it is inert without the private key, which never leaves the agent process.
What it does not close. An attacker with code execution inside the agent process has the private key and can sign proofs. DPoP binds the credential to a key, not to a machine or a human. Say this plainly in the customer docs — the claim is “a stolen token is useless,” not “a compromised agent is contained.”
Replay. Bounded twice: claim_jti burns the proof’s jti for 60 s, and
verify_proof enforces a 30 s age window (_DEFAULT_MAX_AGE_S,
dpop.py:42). A proof captured in flight cannot be reused.
Proof-as-DoS. _MAX_PROOF_BYTES = 8192 (dpop.py:41)
caps parse cost, ALLOWED_ALGS (dpop.py:26) excludes none
and symmetric algorithms, and the check runs only after token verification
succeeds — so an unauthenticated caller cannot trigger the expensive path at
all.
Private key submission. _reject_private_key refuses a JWK containing
d, p, q, dp, dq, qi, k. Tested explicitly, because a client SDK bug that
posts the full JWK is far more likely than an attack, and silently accepting it
would leak the key into Shield’s logs and audit.
Migration path, four rungs:
off, clients start supplyingagent_jwk. Tokens gaincnf. Nothing is enforced. No risk.optional. Proofs are verified and recorded, denied never. Watch the audit forpop_verifiedand for clients sending bad proofs.requiredwithSHIELD_AGENT_TOKEN_POP_ALLOW_UNBOUND=true. Bound tokens must now prove possession; unmigrated clients still work.requiredalone, once the audit shows zero unbound tokens.
Skipping rung 3 is how you take down every client that has not shipped a key yet. The runbook must present these as four steps, not as three modes.
6. Packaging & deploy
- New module: none.
core/agent_tokens.py,core/identity.py,core/agent_identity_middleware.py,core/proxy_trust.py,api/routes_agent_auth.py. Dockerfile.admin: no change needed, and this is worth verifying rather than assuming. The admin plane mints, so it now importscore.dpopviacore.agent_tokens.core/dpop.pyis already in the allowlist at Dockerfile.admin:72, as arecore/agent_tokens.py(line 58) andcore/proxy_trust.py(line 74). Haddpopnot been there, the admin image would crash-loop at boot — exactly the incidenttests/test_admin_dockerfile_imports.pyexists to prevent. That test must pass and is in the DoD.- New pip dependency: none.
cryptographyis already required bycore/agent_tokens.py(agent_tokens.py:56) andcore/dpop.py. - Images to rebuild: both, data plane first. An admin plane minting
cnftokens against a data plane that ignores them is safe (the claim is unknown and unchecked). The reverse — a data plane inrequiredagainst an admin plane that cannot mintcnf— refuses every request. Order matters and must be in the runbook. - Rollback:
SHIELD_AGENT_TOKEN_POP=off. Tokens already carryingcnfremain valid; the claim is simply not checked. No data migration, no re-minting.
7. Failure modes & edge cases
Fail-closed in required, fail-open in optional, inert in off. The helper
never raises: a 500 from a possession check on the guard path is a worse outcome
than a denial, and verify_token_binding already set this precedent.
| condition | optional |
required |
|---|---|---|
| bound token, valid proof | pass, pop_verified=true |
pass |
bound token, no X-Agent-DPoP |
pass, recorded failed | 401 |
| bound token, proof for a different key | pass, recorded | 401 |
| bound token, proof for a different URL or method | pass, recorded | 401 |
| bound token, proof older than 30 s | pass, recorded | 401 |
bound token, replayed jti |
pass, recorded | 401 |
| bound token, proof > 8 KiB | pass, recorded | 401 |
bound token, proof alg: none |
pass, recorded | 401 |
| unbound legacy token | pass | 401, or pass if allow-unbound |
no X-Agent-Token at all |
untouched — middleware returns early at line 28 | same |
| mTLS fallback identity | untouched — no cnf, treated as unbound |
same |
core.dpop import fails |
recorded failed | 401 (closed) |
| nonce store unreachable | claim_jti behaviour, unchanged from SHIELD_TOKEN_BINDING |
same |
behind a proxy, trusted_proxy_only off |
htu compares against the internal URL; proofs signed over the public URL fail |
closed, and this is the most likely misconfiguration |
agent_jwk contains private members |
400 at mint | 400 at mint |
agent_jwk malformed |
400 at mint | 400 at mint |
allow-unbound set in off/optional |
startup warning, no effect | n/a |
The proxy row deserves attention in the runbook. It fails closed, which is correct, but it presents as “PoP is broken” rather than “the proxy boundary is not configured.” The startup log should state which htu source is in effect.
8. Test plan (Definition of Done)
New file tests/test_agent_token_pop.py. Existing DPoP tests cover
verify_proof internals; these cover the agent-token wiring.
Minting
- No
agent_jwk→ nocnfclaim; token verifies as today. - Public JWK →
cnf.jktequalsjwk_thumbprint(jwk). - JWK containing
d→ 400, and the response body does not echo the key. - Malformed JWK → 400.
cnfis minted inoffmode too (the rung-1 property).- Both mint sites (lines 271 and 956) parameterised, as in the delegation spec.
Verification — required
- Valid proof → 200,
pop_verified is True. - Missing
X-Agent-DPoP→ 401agent_pop_required. - Proof signed by a different key → 401.
- Proof with wrong
htm→ 401. - Proof with wrong
htu→ 401. - Proof older than 30 s → 401.
- Replayed
jti→ first 200, second 401. - Proof > 8 KiB → 401.
alg: noneproof → 401.- Unbound legacy token → 401.
- Unbound legacy token + allow-unbound → 200.
core.dpopmonkeypatched to raise → 401.
Verification — optional and off
- Every case in 8 to 16 under
optional→ 200, withpop_verified is False. - Every case under
off→ 200, andverify_agent_popis never called (assert via monkeypatched spy — proves the zero-cost claim, not just the behaviour).
htu canonicalization — the one most likely to be skipped
effective_request_urireturns the internal URL whentrusted_proxy_only()is off, even withX-Forwarded-Hostpresent.- Returns the forwarded URL when the boundary is on and the peer is trusted.
- Ignores
X-Forwarded-Hostwhen the boundary is on and the peer is not trusted. This is the security case: without it a caller picks its own htu. identity_resolution._request_uriandproxy_trust.effective_request_urireturn identical values across all of 21 to 23. The regression guard against the two implementations drifting.
Trusted-proxy exemption (§9)
24a. required + bound token + no proof + trusted-proxy peer → 200,
pop_verified is False.
24b. required + bound token + no proof + peer not trusted → 401. The
exemption must not be reachable without the secret.
24c. required + unbound token + trusted-proxy peer → 200.
24d. Exemption does not apply when trusted_proxy_only() is off, even if the
secret happens to match. Mirrors _proxy_vouched() in
spec-proxy-trusted-role-header.
24e. A valid proof from a trusted-proxy peer still sets pop_verified is True —
the exemption relaxes the requirement, it does not skip verification.
Regression guards
- A token minted with no
agent_jwkinoffmode has a claim set identical to one minted before this change, excludingjti/iat. SHIELD_TOKEN_BINDINGbehaviour is unchanged withSHIELD_AGENT_TOKEN_POPat every value — the two features are independent.- Both enabled with different keypairs,
DPoPandX-Agent-DPoPboth sent → both verify. The header-separation design from §4. - mTLS fallback identity is unaffected in all three modes.
tests/test_agent_auth_portal.py,tests/test_signers.pyand the existing DPoP tests pass unmodified.tests/test_admin_dockerfile_imports.pypasses.
Gate
python -m pytest tests -qgreen in a clean venv.- CI
pytestgate green.
9. Deployment topologies — where PoP applies, and where it cannot
PoP is a direct-path control. This is a property of DPoP, not a gap in the implementation, and the customer-facing claim must be scoped accordingly.
A. Direct — PoP works fully
agent ──► Shield /guardrails/*, cap/mint, tools/call
The agent is the HTTP client, signs a proof over the method and URI it is
actually calling, and verify_proof compares against the same values. This is
where “a stolen agent token is useless” is true, and it covers cap/mint and
tools/call — the two endpoints that actually authorize an action.
B. Behind LiteLLM — PoP cannot work, by construction
agent ──► LiteLLM /v1/chat/completions
└─ VotalGuardrail hook ──► Shield /guardrails/input|output
DPoP binds a proof to htm and htu. The agent signs for
POST /v1/chat/completions at LiteLLM; Shield receives
POST /guardrails/input. The agent cannot sign a proof for a request it does
not make, and LiteLLM cannot sign one because it does not hold the agent’s
private key. Forwarding the proof unchanged fails the htu comparison, and
relaxing that comparison would delete the control.
There is no configuration that fixes this. The options are:
- Trusted-proxy exemption (specified in §4). LiteLLM authenticates itself
with
X-Shield-Proxy-Token; possession of the agent key is not proven on this path, and the audit says so. Chosen. - LiteLLM does its own PoP with LiteLLM’s key. Proves LiteLLM, which the shared secret already does, at the cost of a keypair to manage. Rejected as redundant.
- Re-sign at the hook. Requires the agent’s private key at LiteLLM. Rejected — it destroys the property the feature exists to create.
votal_guardrail.py should still forward X-Agent-Token
(_extract_shield_headers currently forwards neither it nor any proof — grep
-c returns 0). Forwarding it gives Shield verified agent identity on the
LiteLLM path — signature, expiry, revocation, build_hash allowlist — which is
strictly more than the x-agent-key string it gets today. Only possession is
unproven. That is a real improvement and belongs in the same hook PR as the
X-On-Behalf-Of forwarding specified in
spec-proxy-trusted-role-header §10.
What to tell customers
Not “a stolen agent token is useless.” The accurate claim is:
On paths where the agent calls Shield directly — including every tool authorization and capability mint — a stolen agent token is useless without the agent’s private key. Where an LLM gateway sits in front, the gateway authenticates itself to Shield and the audit records that possession was vouched for rather than proven.
The demo in PR 5 must run on a direct path, and the runbook must say why.
10. Task breakdown (one PR each)
PR 1 — shared htu, no behaviour change.
Promote _request_uri to proxy_trust.effective_request_uri, alias the old
name, add tests 21 to 24. Independently correct, and it removes the trap before
anyone can fall into it.
PR 2 — minting. agent_jwk, cnf, _reject_private_key at both sites,
IdentityTuple.cnf_jkt, tests 1 to 6 and 25. Ships rung 1 of the migration: the
fleet can start binding while nothing is enforced.
PR 3 — verification. agent_token_pop_mode(), verify_agent_pop(), the
middleware branch, X-Agent-DPoP, pop_verified in the audit, tests 7 to 20
and 26 to 28. The only PR that can deny a request.
PR 4 — trusted-proxy exemption + X-Agent-Token forwarding.
The §9 exemption in verify_agent_pop, tests 24a to 24e, and the hook change
forwarding X-Agent-Token so the LiteLLM path gains verified agent identity.
Must land with or before any deployment sets required, or every
LiteLLM-routed request 401s.
PR 5 — allow-unbound + operations. The escape hatch, the startup warnings (mode, htu source, misplaced allow-unbound), and the runbook: four migration rungs from §5, image order from §6, the proxy misconfiguration from §7, and the scoped customer claim from §9.
PR 6 — the proof. examples/langchain/agent_token_theft_demo.py, in the
keycloak_binding_demo.py
style: mint a bound token, use it successfully, then replay the same token from
a second process without the private key and show the 401. Doubles as the manual
test and the customer-facing artifact.
Follow-on: binding capability tokens. Same primitives, different latency argument, own spec.