Agent AuthN / AuthZ at Runtime
This document describes how LLM-Shield authenticates and authorizes agent actions at runtime. It covers the threat model, the layered design, the on-the-wire token formats, and how the pieces are wired together in the code.
1. The Problem
Classical IAM (API keys, OAuth, IAM roles) makes assumptions that break for agentic systems:
- One bearer = one principal. Reality: a tool call has a human user, an agent identity, and often an upstream agent that delegated.
- Authorization is identity-only. Reality: whether
send_emailis allowed depends on what’s in the email (PII, taint origin, prior tool outputs), not just who is calling. - Decisions per session. Reality: a single agent session can fan out to hundreds of tool calls across sub-agents; audit needs to be per-action, not per-session.
This implementation closes those gaps with two distinct token types: an agent token (AuthN) and a capability token (AuthZ).
2. Block Diagram
┌──────────────────────────────────────────────────────────────────────────────┐
│ Legend: │
│ ░░░░ AuthN zone — proves WHO (identity, slow-changing, ≤15m) │
│ ▓▓▓▓ AuthZ zone — decides WHAT (permission, per-action, ≤60s) │
│ ████ Enforcement zone — verifies the AuthZ decision at the tool │
└──────────────────────────────────────────────────────────────────────────────┘
Human User (OIDC + MFA) ────► Agent Runtime
░ workload SVID + user id_token
░ ↓
░ POST /v1/shield/auth/agent-token
░ ↓
░ X-Agent-Token = signed agent_token
▼
┌─────────────────────────────────────────┐
│ LLM-SHIELD GATEWAY │
│ │
░ AuthN ──► AgentIdentityMiddleware │
░ verify_agent_token() → IdentityTuple │
░ (request.state.identity) │
│ │
▓ AuthZ ──► POST /v1/shield/cap/mint │
▓ _decide_authz(identity, request): │
▓ - rbac: role → tool, role → data │
▓ - clearance ceiling │
▓ - (taint, delegation, sensitive-action hooks) │
▓ mint_cap(identity, tool, resource, scope, exp≤60s)│
▓ → returns signed cap_token │
└─────────────────────────────────────────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
█ Tool A █ Tool B █ Tool C
POST /v1/shield/cap/verify
- signature, exp, tool match, nonce one-shot
- cap_id revocation check
- returns valid=true|false + claims
Audit log: one signed row per decision.
Control plane: revoke(instance|user|jti).
2a. Customer integration path (no admin key needed)
Customers integrate with the platform using their tenant API key — the same key they use for every other tenant endpoint. They never see the admin key.
The customer-facing endpoints:
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v1/tenant/me/agent-auth/agent-token |
X-API-Key |
Exchange tenant key for an agent token. tenant_id is auto-filled from the key — clients cannot mint tokens for other tenants. |
| POST | /v1/shield/cap/mint |
X-Agent-Token |
Mint a capability for one tool call. |
| POST | /v1/shield/cap/verify |
(none — cap is bearer) | Tool-server side verification + nonce burn. |
| GET | /v1/tenant/me/agent-auth/stats |
X-API-Key |
Per-event counters for the portal. |
| GET | /v1/tenant/me/agent-auth/recent |
X-API-Key |
Last 50 events for the portal. |
The admin endpoints (POST /v1/shield/auth/agent-token,
POST /v1/shield/auth/revoke) stay for control-plane / break-glass use.
A ready-to-paste customer SDK is at examples/shield_client.py. Wrapped
integrations for LangChain and raw OpenAI/Anthropic tool-use are in
examples/langchain_shielded_tool.py and examples/openai_shielded_tool.py.
3. AuthN — Agent Tokens
What an agent token proves
{
user_sub: "user-42" ← OIDC sub of the human
agent_id: "billing-bot" ← logical agent identity
agent_instance_id: "inst-abc-1" ← this running process
parent_agent_id: null | "parent" ← who delegated, if any
tenant_id: "tenant-1"
build_hash: "sha256:..." ← exact code build
model_version: "claude-opus-4.7"
session_id: "sess-123"
iat, exp: Unix seconds ← exp ≤ iat + 15m
jti: "..." ← revocable per-token id
kid: "env" ← signing key id (rotation)
}
Wire format
Standard JWT (RFC 7519) with EdDSA (Ed25519) signatures:
<base64url(header)>.<base64url(claims_json)>.<base64url(ed25519_signature)>
Header: {"alg": "EdDSA", "typ": "JWT", "kid": "<key-id>"}
Verifiable by any standards-compliant JWT library (PyJWT, jose4j, go-jose,
jsonwebtoken). Public keys available at GET /oauth/jwks in OKP JWK format.
Legacy two-segment format (base64url(claims).base64url(sig)) is still
accepted during verification for backward compatibility.
Carried in the X-Agent-Token header.
Verification (core/agent_tokens.py::verify_agent_token)
- Decode and Ed25519-verify the signature with the
kid’s public key. - Check
exp(allows 5s clock skew) andiat. - Enforce required claims.
- If
SHIELD_AGENT_ALLOWED_BUILDSis set, enforce build allowlist. - Check the three revocation lists: instance, jti, user.
On success returns an IdentityTuple (see core/identity.py). On any
failure raises TokenError. The AgentIdentityMiddleware catches that
and returns 401.
Key material
LLM-Shield runs on-prem. No external/cloud key service is required
or assumed. The signer interface (core/signers.py::Signer) supports
three backends, selected per token type at startup. See §10 for details.
| Env var | Effect |
|---|---|
SHIELD_AGENT_TOKEN_PRIVATE_KEY |
Hex-encoded 32-byte Ed25519 private key |
SHIELD_AGENT_TOKEN_KID |
Key id stamped in the kid claim |
SHIELD_SIGNER_BACKEND_AGENT |
local | pkcs11 | vault (default: local) |
If no private key is provided in local mode, the process generates an
ephemeral keypair and emits a warning — dev/test only.
4. AuthZ — Capability Tokens
What a cap token grants
{
user_sub, agent_id, agent_instance_id ← bound to AuthN identity
tenant_id
tool: "send_email" ← exact, not a wildcard
resource: "user/42/inbox" ← exact target
scope: ["to:user@example.com"] ← constraints intersection
clearance_max: "internal" ← data ceiling
parent_cap_id: null | "..." ← for delegated calls
nonce: "..." ← one-shot
iat, exp: Unix seconds ← exp ≤ iat + 60s
cap_id: "..." ← revocable per-cap id
kid: "cap-env"
}
Why a separate signer from agent tokens
Tool servers only need the cap public key — they never need the agent- token public key. A compromised tool can therefore only see (already narrowly-scoped) caps; it cannot forge identity tokens.
Mint (POST /v1/shield/cap/mint)
Requires a verified X-Agent-Token. The handler:
- Pulls
IdentityTuplefromrequest.state.identity. - Runs
_decide_authz(identity, body):- RBAC role → tool check
- RBAC role → data scope check
- Clearance ceiling:
body.clearance_max≤ role’sdata_clearance
- If denied → 403 with reasons (no cap minted).
- If allowed →
mint_cap(...)→ returnscap_token(≤60s TTL).
The decision is also returned in the response for audit.
Verify (POST /v1/shield/cap/verify)
Called by the tool / MCP server before executing. Checks:
- Ed25519 signature with
kid’s public key. exp(allows 2s clock skew).toolmatchesexpected_tool.- (Optional)
resourcematchesexpected_resource. cap_idnot in revocation list.- Atomically burn
nonce(Redis SETNX, or fallback dict). On second use → “replay detected”.
Returns {valid, claims} or {valid: false, error}.
4a. Defense-in-depth (H1, H3, M1, M2, M3, M5)
These are the runtime hardenings on top of the core scheme. Each maps to a specific failure mode a real adversary would exploit.
| Defense | Env var | What it stops |
|---|---|---|
iss + aud claim binding |
SHIELD_ISSUER, SHIELD_AGENT_AUDIENCE, SHIELD_CAP_AUDIENCE |
Tokens from one Shield deployment validating in another; a leaked agent-token verifying as a cap |
| Token-issuance rate limit | SHIELD_RATELIMIT_TOKEN_PER_MIN (60), SHIELD_RATELIMIT_TOKEN_PER_DAY (100k) |
Stolen tenant API key minting unbounded tokens |
| Cap-mint rate limit | SHIELD_RATELIMIT_CAP_PER_MIN (600), SHIELD_RATELIMIT_CAP_PER_DAY (1M) |
One noisy/malicious agent_instance_id burning all caps |
| Multi-worker hard-fail | SHIELD_ALLOW_INMEMORY_MULTIWORKER (override) |
Per-worker nonce/rate-limit state allowing replays across processes |
| Retired-kid blocklist | SHIELD_RETIRED_KIDS=kid-old1,kid-old2 |
Rotated keys still validating tokens after key rotation |
| Quiet denial mode | SHIELD_VERBOSE_REASONS (default off) |
Attackers enumerating RBAC roles/tools via the denial API. Full reasons still in the audit log. |
Defaults are secure. Production deployments only need to set SHIELD_ISSUER
to a per-environment value (shield-prod, shield-staging) and provide Redis.
5. Revocation
Three independent axes (storage/revocation.py):
| Function | What it kills |
|---|---|
revoke_instance |
One running agent process |
revoke_user |
Everything a compromised user did |
revoke_jti |
One specific token or cap (by jti/cap_id) |
Backed by Redis when present; in-process fallback otherwise. TTL = 3600s by default (longer than max token lifetime), so revoke entries auto-clean.
Exposed via POST /v1/shield/auth/revoke (admin-key gated).
Revocation checks run inside verify_agent_token and verify_cap, so
revocation propagates within one verify call — no caches to bust.
6. End-to-end Sequence
1. User logs in (OIDC) → id_token
2. Agent starts (workload SVID) → workload identity
3. POST /v1/shield/auth/agent-token → agent_token (≤15m)
(token exchange — gated by SHIELD_ADMIN_KEY in v1)
4. Agent calls /v1/shield/cap/mint with X-Agent-Token
- middleware verifies → IdentityTuple
- policy decides AuthZ
- mint cap → cap_token (≤60s)
5. Agent passes cap_token to the tool server
6. Tool calls /v1/shield/cap/verify
- sig + exp + tool match + nonce burn → valid|invalid
7. Tool executes the action (only if valid).
8. If something looks wrong:
POST /v1/shield/auth/revoke {agent_instance_id|user_sub|jti}
- propagates instantly on the next verify call.
7. Threat → Control Matrix
| Threat | Control |
|---|---|
| Stolen long-lived API key | No long-lived agent secret; tokens ≤15m, Ed25519-signed |
| Replayed capability token | One-shot nonce burn at verify |
| Prompt-injected tool abuse | Cap minted before LLM sees user instruction; tool field is exact, not wildcard |
| Confused-deputy / over-broad sub-agent | Cap carries scope intersection; downstream cannot widen |
| Tool server trusts agent’s word | Tool verifies cap signature locally against distributed pubkey |
| Tampered agent build / wrong model | build_hash claim checked against allowlist |
| Compromised agent in flight | revoke_instance(agent_instance_id) — effect ≤1s |
| Cross-tenant leakage | tenant_id in every cap, checked constant-time at tool |
| “How did this happen?” 3 weeks later | One signed audit row per decision, replayable |
8. Production Hardening
This is an on-prem product. No cloud-managed services are required.
Implemented
- Standard JWT format (RFC 7519) with
alg: EdDSA. Tokens are verifiable by any JWT library. JWKS endpoint atGET /oauth/jwks. - OAuth 2.1 authorization server with PKCE, dynamic client registration (RFC 7591), and token exchange (RFC 8693). MCP clients connect via standard OAuth flows.
- External OIDC integration — validate id_tokens from Keycloak, Okta, Auth0, Azure AD. Per-tenant provider configuration.
- SPIFFE workload identity — accept JWT and X.509 SVIDs as alternative to admin-key for automated workloads. On-prem friendly with local trust bundles and JWKS files.
- mTLS middleware — extract client cert identity from
X-Forwarded-Client-Cert(Envoy/Istio) or direct TLS. - A2A protocol — Google Agent-to-Agent support with Agent Card discovery, task lifecycle, and SSE streaming. All messages route through the guardrail pipeline.
Planned
- Move signing into an HSM or Vault for environments that need it.
The
Signerinterface is already in place; see §10. - Stream audit rows to immutable on-prem storage (Kafka → ClickHouse, or write-once object storage like MinIO with object lock). Inline DB writes do not scale to high tool-call rates.
- Bloom-filter front the revocation store to skip ~99% of Redis GETs. Revocation is rare, so a per-pod 1MB bloom absorbs the hot path and only falls through to Redis for true positives + false positives.
- Shard Redis by tenant so one noisy tenant cannot saturate the revocation store for others.
- Ship a tool-server SDK (Python + TS) that bundles cap verification with the public key, so tools do not need an HTTP call to Shield. Public keys are distributed out-of-band (config, secret store).
9. Scale Notes
The protocol is stateless on the verify side, so adding Shield pods scales linearly. The bottlenecks are storage-layer:
| Component | Bottleneck | On-prem mitigation |
|---|---|---|
| Agent-token verify | 3 Redis GETs (revocation) | Per-pod bloom filter |
| Cap verify | 1 Redis SETNX (nonce) | Redis Cluster sharded by nonce |
| Cap signing throughput | Sequential HSM calls | LocalEd25519Signer (≈30µs/sig) or envelope signing through Vault |
| Audit fan-out | Synchronous writes | Async producer → Kafka |
The token formats and verify logic do not change with these — they are storage-layer swaps behind existing interfaces.
10. Signing Backends
core/signers.py defines a Signer protocol with three backends. Both
agent-token signing and cap-token signing dispatch through it
independently, so a deployment can use (for example) HSM for agent
tokens and the local signer for caps.
Selection
SHIELD_SIGNER_BACKEND # default for both (default: local)
SHIELD_SIGNER_BACKEND_AGENT # override for agent tokens
SHIELD_SIGNER_BACKEND_CAP # override for caps
Values: local (default), pkcs11, vault.
LocalEd25519Signer (default)
Process-resident Ed25519 keypair. Suitable for:
- hardened hosts (key mlock’d, host-disk-encrypted, TPM-sealed at boot),
- dev / staging,
- deployments where throughput dominates HSM compliance requirements.
Throughput: ~30µs/sign, ~50µs/verify, no I/O. One 8-core pod sustains ~150k verifies/sec.
PKCS11Signer (stub — production-ready shape)
Delegates signing to an HSM via PKCS#11. Verify stays in-process using a public key loaded from disk at startup — no HSM round trip on the hot path.
Config:
SHIELD_PKCS11_LIBRARY path to e.g. libsofthsm2.so
SHIELD_PKCS11_TOKEN_LABEL HSM token label
SHIELD_PKCS11_KEY_LABEL private-key label inside the token
SHIELD_PKCS11_USER_PIN_FILE file containing the user PIN (mode 0400)
SHIELD_PKCS11_PUBLIC_KEY path to raw 32-byte Ed25519 pubkey
To activate: pip install python-pkcs11, then fill in the sign()
method per the docstring. The stub raises a clear NotImplementedError
if instantiated unconfigured.
Throughput: typically 1k–10k sigs/sec depending on HSM hardware. Below the local backend, but offers tamper-resistant key storage and FIPS 140-2 Level 3 attestations where compliance requires.
VaultTransitSigner (stub — production-ready shape)
Delegates signing to HashiCorp Vault’s Transit secret engine. Verify again stays local.
Config:
SHIELD_VAULT_ADDR e.g. https://vault.internal:8200
SHIELD_VAULT_TOKEN_FILE AppRole / token file path (mode 0400)
SHIELD_VAULT_TRANSIT_KEY transit key name (must be Ed25519)
SHIELD_VAULT_PUBLIC_KEY path to raw 32-byte Ed25519 pubkey
SHIELD_VAULT_NAMESPACE (optional, Enterprise)
To activate: pip install hvac, then fill in sign() per the
docstring. Throughput: ~3k sigs/sec per Transit instance; for higher
throughput, run Transit in HA + batch mode, or implement envelope
signing (cache a Vault-issued short-lived data key in pod memory,
rotate every N minutes).
Why public-key verify is always local
The hot path is verify, not sign. Routing verify through an HSM or
Vault would impose a network round trip on every tool call — that is
the wrong tradeoff. All three backends load the public key from disk
at startup and verify with cryptography, keeping verify CPU-bound and
under 100µs.
11. Where the code lives
Core AuthN/AuthZ
| File | Role |
|---|---|
core/identity.py |
IdentityTuple + request dependency |
core/signers.py |
Signer protocol + 3 backends |
core/jwt_utils.py |
JWT encode/decode/JWKS (RFC 7519) |
core/agent_tokens.py |
Mint/verify agent tokens (JWT) |
core/capabilities.py |
Mint/verify capability tokens (JWT) |
core/agent_identity_middleware.py |
Verifies X-Agent-Token + mTLS fallback |
storage/revocation.py |
Three revocation axes |
api/routes_agent_auth.py |
All HTTP endpoints + AuthZ decision |
OAuth 2.1 / OIDC / Federation
| File | Role |
|---|---|
core/oauth/authz_server.py |
OAuth 2.1 authorization server core |
core/oauth/pkce.py |
PKCE (RFC 7636) utilities |
core/oauth/oidc_client.py |
External OIDC relying party |
core/oauth/jwks_cache.py |
JWKS key cache (Redis + in-memory) |
core/oauth/spiffe.py |
SPIFFE SVID validation |
core/oauth/spiffe_middleware.py |
SPIFFE request middleware |
core/mtls_middleware.py |
mTLS client cert extraction |
storage/oauth_store.py |
OAuth clients, codes, refresh tokens |
api/routes_oauth.py |
OAuth endpoints (metadata, token, JWKS) |
api/routes_oauth_registration.py |
Dynamic client registration (RFC 7591) |
api/routes_oidc_admin.py |
OIDC provider management |
A2A Protocol
| File | Role |
|---|---|
core/a2a/agent_card.py |
A2A Agent Card schema |
core/a2a/task.py |
A2A Task lifecycle |
api/routes_a2a.py |
A2A endpoints + guardrail routing |
Tests
| File | Role |
|---|---|
tests/test_agent_tokens.py |
AuthN unit tests |
tests/test_capabilities.py |
AuthZ unit tests |
tests/test_revocation.py |
Revocation tests |
tests/test_signers.py |
Signer dispatcher + backend tests |
tests/test_agent_auth_e2e.py |
End-to-end FastAPI tests |
Customer Resources
| File | Role |
|---|---|
docs/customer-auth-flow.md |
Step-by-step integration guide |
demos/customer_auth_flow.sh |
Runnable demo script (success+failure) |