FAQ: verified identity — what Shield proves, and what it doesn’t
Written for the questions a security architect asks in the first twenty minutes. Answers state the current position, not the roadmap. Where a control is incomplete it says so, with the flag or spec that closes it.
Do I have to replace my identity provider?
No. Shield authenticates nobody.
It is a credential service, not an identity provider. Keep Okta, Entra, Keycloak or Auth0 for your people and SPIFFE or mTLS for your workloads. Shield consumes both and derives a short-lived credential from them:
your IdP -> which human ─┐
SPIFFE / mTLS -> which workload ─┼─> Shield -> agent token -> capability
Shield registry -> which agent, which tools ─┘
There is no user store, no login flow, no MFA, no SCIM. Nothing about your existing identity estate moves.
Why isn’t our existing workload identity enough?
Because workload identity answers one question and agent authorization needs seven. SPIFFE, mTLS and Kubernetes ServiceAccount tokens were designed to say which service is calling. That is necessary and it is not sufficient.
| Question | Workload identity | Shield agent token |
|---|---|---|
| Which service? | yes | agent_id |
| Which process? | roughly (the pod) | agent_instance_id |
| Which build of the agent? | no | build_hash |
| Which model version? | no | model_version |
| Acting for which human? | no | user_sub |
| Delegated by which agent? | no | parent_agent_id, delegation_depth |
| Which task or session? | no | session_id |
Four consequences, in practice:
- Same code, different behaviour. A service with fixed code behaves the
same tomorrow. An agent with the same code and new model weights does not.
Identity that stops at “which binary” cannot tell you whether an incident came
from the code or the model, so
model_versionis part of the credential. - Delegation is native, not bolted on. Services call services as themselves. Agents act on behalf of a human and spawn sub-agents. Authorization is the intersection — the agent’s grant AND the user’s role — so neither can exceed its own.
- Lifetime. A workload identity lives as long as the pod, in hours or days. An agent’s authority should live as long as the task. Agent tokens are capped at 15 minutes and each action gets its own single-use capability.
- Revocation granularity. Revoking workload identity means killing the pod. Shield revokes one agent instance or one leaked token without touching the deployment.
If you already run SPIRE or issue service-account tokens, Shield verifies those
rather than asking you to adopt a second system. Set
SHIELD_WORKLOAD_IDENTITY_PROVIDERS to the ones you have.
What identity does Shield actually accept?
Four levels. Most deployments run at level 2.
| Level | What it is | What it proves |
|---|---|---|
| 1 | X-Agent-Key header |
Nothing — a claimed string. Useful for discovering unregistered agents, not for authorization |
| 2 | Shield-issued agent token | Ed25519-signed, ≤15 min, carries the full claim set above. Holding it is the proof |
| 3 | Level 2 bound to a keypair | Holding the token is not enough — see the possession question below |
| 4 | SPIFFE SVID, mTLS cert, OIDC service account | Verified against your existing infrastructure; Shield issues nothing |
Above all of it sits the agent registry: agents are registered per tenant with the tools they may call. In managed mode an unregistered agent cannot get a token at all. Tenants with no registry stay permissive so you can bootstrap.
Is X-User-Role trusted?
By default, yes — it is a caller assertion, exactly like user_role in a
request body. Anything that can reach the API can claim any role.
SHIELD_ROLE_BINDING changes that. There are four modes:
| mode | verified claim present | no verified claim |
|---|---|---|
off (default) |
header wins | header wins |
prefer |
claim wins, header ignored | header wins |
strict |
claim wins, header ignored | no role |
strict_proxy |
claim wins, header ignored | header accepted only from the trusted proxy |
The decision records role_source, so an audit entry distinguishes oidc from
header from proxy.
Three things to know before relying on it:
preferdoes not close the forgery. It only upgrades callers that present a credential. A caller that presents none and sendsX-User-Role: adminstill getsadmin, because there is no verified claim to prefer. On a publicly reachable deployment,preferis not a control.- If the verifying provider is misconfigured — issuer mismatch, missing
audience — verification returns nothing. Under
preferthe header is then used and no error is raised. Test it by sending a request with no credential and a claimed privileged role; if it succeeds, binding is not enforcing. strict_proxyaccepts a self-asserted role only from a peer that presentsX-Shield-Proxy-Token. It is recorded asrole_source: proxyandrole_verified: false— the proxy vouched for the hop, it did not prove the user’s credential to Shield. See the role-binding runbook.
Can a caller mint an agent token naming someone else?
Not when a verified user credential is present. user_sub is checked against
the subject of that credential and a mismatch is refused:
403 user_sub 'ceo@example.com' does not match the verified subject 'user-abc'
When no user credential is presented, user_sub is still caller-supplied.
This control closes “the claim contradicts the proof”, not “there is no
proof”. Requiring proof is role binding’s job, above.
Can one tenant mint credentials for another?
No. A tenant API key authorizes issuance for the tenant it resolves to and no
other; the body’s tenant_id must match, and an empty value is treated as a
mismatch rather than consent. The check binds to the resolved tenant, never
the requested one.
Operator admin keys are deliberately not tenant-bound — that is what makes them
operator credentials. Do not distribute them to tenants; use the tenant_key
provider instead.
Does a capability token prove who authorized the action?
It proves a decision was made, by whom it was claimed, and exactly what was permitted: one tool, one resource, one agent instance, a short TTL, single use. The signature is real and replay is rejected.
It does not yet record how the identity in it was established. A capability
minted over a header-claimed role is byte-indistinguishable from one minted
over a verified OIDC claim. If your threat model needs that distinction — and
for a non-repudiation argument it does — it is not there today. Carrying
role_source / identity_method / trust_level into the cap claims is the
planned fix.
Read a capability as “this decision was made under these constraints”, not “this person was proven to be who they said”.
What stops an agent skipping Shield entirely?
At tool/check, nothing — it is advisory. Shield answers allow/deny and the
client chooses to honour it. That is appropriate for guardrail screening and
insufficient for authorization.
The capability path is the enforcing one: the tool server refuses to act without a valid, unburned capability, so skipping Shield means the tool does not run. Use it for actions that matter.
Is the agent’s own identity verified, or just the user’s?
Both, but the agent’s identity is a bearer token by default: the agent token is signed by Shield and cannot be forged, yet anything holding it is that agent. A copy in a log file, crash dump, or proxy access log works until it expires.
Three ways to close that, none of them on by default:
SHIELD_AGENT_TOKEN_POPbinds the token to a keypair. See below.- mTLS / SPIFFE bind identity to a workload rather than a secret. Neither is in the default provider chain — enable explicitly.
Can a stolen agent token be used by someone else?
By default, yes, for up to 15 minutes. That lifetime is a mitigation, not a control.
With SHIELD_AGENT_TOKEN_POP=required it cannot. The agent generates a keypair,
sends Shield only the public half at mint, and signs a proof on every
request with the private half. Shield stores no keys — the thumbprint travels
inside the signed token. The proof also covers the method and URI being called,
so a captured proof cannot be replayed or redirected.
Two limits, stated plainly because you will be asked:
- It does not contain a compromised agent. An attacker with code execution inside the agent process has the private key. This binds a credential to a key, not to a machine or a person.
- It cannot work behind an LLM gateway. The proof covers the URL being
called; the agent signs for the gateway’s URL and Shield receives its own.
There is no configuration that fixes this — it is the mechanism. On that path
the gateway authenticates itself instead and the audit records
pop_verified: false, so a vouched request stays distinguishable from a proven one.
cap/mint and tools/call — the endpoints that actually authorize an action —
are direct paths, so the guarantee holds where it matters. The accurate claim:
On paths where the agent calls Shield directly, a stolen agent token is useless without the agent’s private key.
examples/langchain/agent_token_theft_demo.py runs it: the thief holds the
complete token every time, and every attempt is refused for a different reason.
Can an agent delegate its way around its own permissions?
By default the delegation record is not trustworthy: parent_agent_id is
whatever the caller put in the request body, and Shield signs it. Nothing
verifies the named parent exists or delegated anything. No authorization
decision reads it, so the impact is a poisoned audit trail rather than an
escalation — but that still matters if you rely on the audit.
SHIELD_DELEGATION_PARENT_PROOF=required derives the parent from a verified
parent token the caller must present, and refuses a parent belonging to a
different tenant. SHIELD_MAX_DELEGATION_DEPTH then bounds the chain, enforced
both when a token is minted and when it is verified, so lowering the limit takes
effect immediately.
The order matters: a depth limit without parent proof bounds nothing, because the depth is computed from the parent the caller named. Shield logs a warning at boot if you configure it that way.
Known limit: revoking a parent does not revoke its children. A child stays valid until its own expiry, capped at 15 minutes. Cascading revocation would need a lookup on every guarded request; the short lifetime is the mitigation instead.
examples/langchain/delegation_chain_demo.py shows the refusals.
Are policies deterministic?
Partly. RBAC (role → tool, role → data) is deterministic and reads from the agent registry — the matrix in the portal is what is enforced.
Custom policies and payload/data policies are natural-language rules evaluated by an LLM. That is flexible and it is not auditable in the way a rule engine is: an evaluation can reach a conclusion no configured rule states. Use deterministic RBAC for anything that must be certain, and treat LLM policy as defence in depth on top.
What is the latency cost?
Measured against a deployed data plane: input guardrails 4–17s, tool authorization 1–6s, capability mint ~1.3s, verify ~0.9s. A tool call on the enforcing path is therefore several seconds on top of model latency.
This is the most common reason a pilot stalls. Budget for it, measure your own numbers, and decide per tool whether the capability path is warranted.
How do I verify any of this myself, rather than take it on trust?
Five checks, in order. Run them against your deployment, not a local instance.
- No credential, privileged role. Send
X-User-Role: <privileged>with no bearer. If it is allowed, role binding is not enforcing on your deployment. - Cross-identity mint. Present a valid user credential and a different
user_sub. It must 403. - Replay. Mint a capability, use it, present it again. The second must fail with a replay error — that also proves the nonce store is shared across workers rather than per-process.
- Token theft. Copy an agent token to a second process without the private
key and repeat a call. Under
SHIELD_AGENT_TOKEN_POP=requiredit must 401. - Chain depth. Mint a delegation chain one hop past
SHIELD_MAX_DELEGATION_DEPTH. It must 403.
examples/cap_flow_demo.py runs 2 and 3,
examples/langchain/agent_token_theft_demo.py runs 4, and
examples/langchain/delegation_chain_demo.py runs 5. Each prints the reason for
every refusal. A refusal for the wrong reason is reported as a failure, because
a control that refuses for an unrelated reason is not the control you tested.
Which of these are on by default?
None of the hardening. Shield ships permissive so an install does not break, which means an unconfigured deployment proves considerably less than this page might suggest.
| Control | Env | Default |
|---|---|---|
| Role binding | SHIELD_ROLE_BINDING |
off |
| Delegated user | SHIELD_DELEGATION |
off |
| Agent-token possession | SHIELD_AGENT_TOKEN_POP |
off |
| Proven delegation parent | SHIELD_DELEGATION_PARENT_PROOF |
off |
| Chain depth limit | SHIELD_MAX_DELEGATION_DEPTH |
unlimited |
| Workload token binding | SHIELD_TOKEN_BINDING |
off |
| Trusted-proxy boundary | SHIELD_TRUSTED_PROXY_ONLY |
off |
| Identity providers | SHIELD_WORKLOAD_IDENTITY_PROVIDERS |
admin_key,spiffe |
Note the last row: oidc_sa is not in the default chain, so a Keycloak or
Okta bearer token is not verified until you add it.
Turn them on in the order given in the role-binding runbook — several interact, and two combinations fail closed in ways that look like a broken feature rather than a missing setting.