Shield Identity Bundle — on-prem workload identity, as separate containers
The identity layer ships as its own containers, not baked into the Shield image. You run it alongside your existing Shield deployment and turn it on with a compose profile (or a Helm flag). Nothing here runs unless you enable it, so existing deploys are unchanged.
What it deploys
| Container | Role | Image |
|---|---|---|
spire-server |
Issuer / CA for your trust domain (embedded SPIRE) | ghcr.io/spiffe/spire-server |
spire-agent |
Node + workload attestation; Workload API socket | ghcr.io/spiffe/spire-agent |
envoy |
mTLS front door: verifies the SVID (real crypto) → injects X-Forwarded-Client-Cert → proxies to Shield |
envoyproxy/envoy |
Shield itself is unchanged — it consumes the Envoy-verified identity through
the spiffe/mtls workload-identity provider. Envoy does the cryptographic
verification, so Shield’s own X.509 check is never the gate in this topology.
Topology
flowchart LR
subgraph UZ["Untrusted zone"]
A["Agent workload"]
end
subgraph BUNDLE["Shield Identity Bundle (opt-in containers)"]
direction TB
SS["SPIRE Server<br/>CA · registration"]
SA["SPIRE Agent<br/>Workload API socket"]
EV["Envoy front door<br/>mTLS verify · inject XFCC + secret"]
SS --- SA
end
SH["Shield data plane<br/>workload-identity providers →<br/>agent token · cap mint · guardrails"]
A -- "1 fetch SVID (SDS)" --> SA
A -- "2 mTLS + SVID" --> EV
EV -- "3 verified XFCC + X-Shield-Proxy-Token" --> SH
SH -- "4 agent_token / cap_token" --> A
SA -. "trust bundle (SDS)" .-> EV
Trust boundaries: Envoy performs the real mTLS verification (proof-of-possession)
and is the only ingress to Shield; Shield honors the identity only when the
X-Shield-Proxy-Token secret proves the request came through Envoy. A client that
reaches Shield directly has neither a verified SVID nor the secret.
Deploy steps
1. Pick your trust domain (customer-supplied, no vendor default):
export SPIRE_TRUST_DOMAIN=bank-co.internal
2. Bring up the bundle alongside Shield:
cd deploy/identity
docker compose -f docker-compose.identity.yml --profile identity up -d
This starts spire-server, spire-agent, and envoy. Point Envoy’s upstream
at your Shield service (edit shield_upstream in envoy.yaml, default
shield:8000).
3. Register the agent workload with SPIRE (what selector → which SPIFFE ID):
# one join token to enroll the node
docker compose exec spire-server \
/opt/spire/bin/spire-server token generate -spiffeID spiffe://$SPIRE_TRUST_DOMAIN/agent/support-bot
# map a workload (here: unix uid 1000) to that SPIFFE ID
docker compose exec spire-server /opt/spire/bin/spire-server entry create \
-parentID spiffe://$SPIRE_TRUST_DOMAIN/spire/agent/join_token/<token> \
-spiffeID spiffe://$SPIRE_TRUST_DOMAIN/agent/support-bot \
-selector unix:uid:1000
Kubernetes: a Helm chart (
deploy/helm/shield-identity/) provides the Envoy front door + aClusterSPIFFEIDfor pod-annotation auto-registration (via the SPIRE Controller Manager). It’shelm lint/template-validated in CI but not yet cluster-validated — see the chart README. SPIRE itself is installed separately via the official hardened chart.
4. Configure Shield to accept the identity (env on the Shield service):
SHIELD_WORKLOAD_IDENTITY_PROVIDERS=spiffe,mtls,admin_key
SHIELD_SPIFFE_ENABLED=true
SHIELD_SPIFFE_TRUST_DOMAIN=$SPIRE_TRUST_DOMAIN
SHIELD_SPIFFE_TRUST_BUNDLE=/run/spire/bundle.pem # SPIRE-exported bundle
SHIELD_SPIFFE_ALLOWED_WORKLOADS=spiffe://$SPIRE_TRUST_DOMAIN/agent/support-bot
5. The agent fetches its SVID and calls Shield through Envoy:
agent → (Workload API socket) SVID → mTLS to envoy:8443
→ Envoy verifies SVID, injects XFCC → Shield
→ POST /v1/shield/auth/agent-token → agent_token
→ /cap/mint per tool call → guarded tool calls
See examples/langchain/spiffe_guarded_e2e.py for the client side.
The “no SPIFFE” alternative (same bundle, simpler)
If a customer won’t run SPIRE, they don’t need this bundle at all — they use the
oidc_sa provider instead:
SHIELD_WORKLOAD_IDENTITY_PROVIDERS=oidc_sa,admin_key
SHIELD_WORKLOAD_OIDC_ISSUERS=https://kubernetes.default.svc # cluster is an OIDC issuer
SHIELD_WORKLOAD_OIDC_AUDIENCE=shield
The agent sends its projected Kubernetes ServiceAccount token (or a corporate IdP
JWT) as Authorization: Bearer; Shield verifies it against the issuer’s JWKS. No
SPIRE, no Envoy, no shared secret.
Hard requirements (or the mTLS gate is bypassable)
- Only Envoy may reach the Shield data plane — network-policy Shield so agents cannot connect to it directly.
- Shield trusts XFCC only from Envoy — two layers:
- Envoy is configured
SANITIZE_SET, so client-supplied XFCC is stripped. - Shield enforces it too: set
SHIELD_TRUSTED_PROXY_ONLY=trueand a high-entropySHIELD_TRUSTED_PROXY_SECRET. Envoy injects that secret asX-Shield-Proxy-Token(seeenvoy.yamlrequest_headers_to_add,OVERWRITE_IF_EXISTS_OR_ADDso a client-supplied copy is stripped). Shield honors the XFCC identity only when the secret matches. - Use the secret, not just IPs. Source-IP matching
(
SHIELD_TRUSTED_PROXY_IPS) is only reliable if the server does not trustX-Forwarded-Forfrom untrusted peers — under uvicorn’sproxy_headerswith a permissiveFORWARDED_ALLOW_IPS,request.client.hostis derived from a client-controlled header and is spoofable. The secret is IP-independent and is the authoritative gate; keepSHIELD_TRUSTED_PROXY_IPSonly as an additional constraint. (Default off → unchanged when unset.)
- Envoy is configured
scripts/smoke_identity_bundle.sh asserts the whole boundary — five checks:
- Bundle up — Envoy responds over mTLS.
- Valid SVID mints a token — the happy path end to end.
- No client cert is refused — the mTLS gate engages.
- Forged / self-signed SVID rejected at Envoy (real chain verification).
- Client-supplied XFCC is stripped — a spoofed header does not grant identity.
A non-zero exit means one of these failed; run it after every deploy and as the pre-release gate.
Production profile
The dev bundle (deploy/identity/) uses SQLite + join_token + insecure_bootstrap
— fine for a PoC, not for production. A hardened profile lives in
deploy/identity/prod/:
cd deploy/identity/prod
export SPIRE_TRUST_DOMAIN=bank-co.internal
export SPIRE_DB_CONN='postgres://spire:***@spire-db:5432/spire?sslmode=require'
export SPIRE_DB_PASSWORD=*** SHIELD_TRUSTED_PROXY_SECRET=$(openssl rand -hex 32)
# mount your PKI material (node CA + upstream signing cert/key) into ./conf
docker compose -f docker-compose.prod.yml --profile identity up -d
What the prod profile changes:
| Concern | Dev | Prod (deploy/identity/prod/) |
|---|---|---|
| Datastore | SQLite | Postgres (shared across HA replicas) |
| Node attestation | join_token |
x509pop (or k8s_psat on Kubernetes) |
| Server bootstrap | insecure_bootstrap |
pre-shared trust bundle |
| Signing root | self-signed | UpstreamAuthority → your PKI (swap disk for vault / KMS) |
| Availability | single | 3+ replicas behind Postgres |
Also required in production (not just SPIRE):
- Trust bundle rotation: SPIRE rotates the CA; Envoy gets it live over SDS, and
Shield’s
SHIELD_SPIFFE_TRUST_BUNDLEmust be refreshed (spiffe-helper). - The proxy secret: set
SHIELD_TRUSTED_PROXY_SECRETon Shield and the same value in Envoy’sX-Shield-Proxy-Token(seeenvoy.yaml) — do not rely on source-IP matching alone (see Hard requirements above). - Federation: to trust another cluster’s SPIRE, configure SPIRE federation instead of running a second issuer.
- On Kubernetes: use the
deploy/helm/shield-identity/chart (Envoy front doorClusterSPIFFEIDauto-registration). Install the official SPIRE hardened chart first, then this one. It ishelm lint/template-validated in CI but not yet cluster-validated — run it on akindcluster + the smoke script before prod.
Client integration
The agent workload needs to (1) get its SVID and (2) present it over mTLS to Envoy. Both are language-agnostic — the SVID comes from the SPIRE Agent’s Workload API unix socket, and the mTLS is standard TLS with a client cert.
- Python:
examples/langchain/spiffe_guarded_e2e.py(uses thespiffelibrary). Install withpip install -r requirements-spiffe.txt. - Go:
examples/identity/go-agent— a compilinggo-spiffeexample (fetch SVID → mTLS → mint agent token). - Java / Node: use the official SPIFFE libraries against the same socket —
java-spiffe(Java),@spiffe/svid(Node). The contract below is all you need.
Fetch the SVID (Go, via go-spiffe):
// streams the SVID + trust bundle from the Workload API and auto-rotates
src, _ := workloadapi.NewX509Source(ctx,
workloadapi.WithClientOptions(workloadapi.WithAddr("unix:///tmp/spire-agent/public/api.sock")))
tlsConfig := tlsconfig.MTLSClientConfig(src, src, tlsconfig.AuthorizeAny())
client := &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}
// client now presents the SVID on every request to https://envoy:8443
SVID caching & rotation: don’t cache the cert yourself. The Workload API
streams the SVID and pushes a new one before the old expires (default TTL
1h) — go-spiffe/py-spiffe’s X509Source handles rotation transparently. If you
must use files (e.g. a non-Go sidecar), run spiffe-helper, which rewrites the
cert/key/bundle on rotation; point your client at those paths and reload on change.
What Envoy sends Shield — after verifying the SVID, Envoy sets
X-Forwarded-Client-Cert (XFCC). It looks like:
X-Forwarded-Client-Cert: By=spiffe://bank-co.internal/shield;
Hash=<sha256>;URI=spiffe://bank-co.internal/agent/support-bot
Shield’s spiffe provider reads the URI=spiffe://… SAN as the workload identity.
Your client never sets this header — Envoy does, and strips any client-supplied copy.
Production operations
Failure modes & recovery (fail-closed)
| Event | Behavior | Recovery |
|---|---|---|
| spire-server down | Agents keep working until their SVID TTL (≤1h) expires; no new SVIDs issue. Once expired, Envoy rejects the handshake → Shield token issuance fails closed (403). | Run 3+ HA replicas so a single server loss is invisible. Restart/replace the server before TTLs lapse. |
| Postgres down | SPIRE can’t issue or rotate → same expiry-driven fail-closed. | HA Postgres + restore from backup (below). |
| CA / trust-bundle rotation | SPIRE rotates automatically; Envoy gets the new bundle live over SDS. | Refresh Shield’s SHIELD_SPIFFE_TRUST_BUNDLE (spiffe-helper) — see runbook below. |
| Clock skew | SVIDs are time-bound; skew causes spurious rejects. | Require NTP on all nodes. |
| Proxy-secret rotation | Mismatch between Envoy and Shield → all identity rejected. | Roll with an overlap: set the new secret on Shield first (accept old+new briefly if you templated it), update Envoy, then drop the old. |
Backup / DR (Postgres): the SPIRE datastore is the source of truth for
registration entries and keys. Back it up like any critical DB
(pg_dump on a schedule, WAL archiving / PITR for RPO≈0). Losing it means
re-registering every workload and re-bootstrapping trust.
CA-rotation runbook: SPIRE rotates its CA within ca_ttl (24h default).
Envoy consumes the new bundle over SDS with no action. For any consumer reading a
file bundle (SHIELD_SPIFFE_TRUST_BUNDLE), run spiffe-helper so the file is
rewritten on rotation, or re-export with
spire-server bundle show > bundle.pem on a timer shorter than ca_ttl.
SPIRE upgrade path: upgrade the server first, then agents (agents are
backward-compatible with a newer server, not vice-versa). Pin image tags
(ghcr.io/spiffe/spire-server:1.9.0); test the target version in staging with the
smoke script before rolling prod.
Observability — what to watch
There is no Prometheus /metrics endpoint yet (roadmap). Use what exists:
| Signal | Where |
|---|---|
| Shield liveness + build | GET /health |
| Guardrail effectiveness | GET /v1/tenant/me/guardrails/metrics (JSON) |
| Token issuance / blocks | Shield structured logs (agent_chat_telemetry, audit log) |
| Envoy mTLS + routing | Envoy access logs + admin /stats (TLS handshake failures, 4xx to upstream) |
| SPIRE health | spire-server healthcheck, spire-agent healthcheck |
| SVID issuance / rotation | SPIRE server/agent logs; spire-server entry show |
Alert on: rising Envoy TLS handshake failures (bad/expired SVIDs), Shield 403s
on /auth/agent-token (identity rejected), and SPIRE server unavailability.
Sourcing secrets
Don’t leave SHIELD_TRUSTED_PROXY_SECRET or the Postgres password as literals in
compose files or shell history:
- Docker/compose: use
secrets:(files under/run/secrets) and read them in an entrypoint, or inject from your orchestrator’s env at runtime. - Kubernetes: a
Secretmounted as env/file; or Vault Agent / External Secrets Operator / sealed-secrets to sync from a manager. - Vault:
vault kv getin an init step, or the Vault Agent sidecar templating the value into a file the container reads. For the SPIRE signing root, prefer thevaultUpstreamAuthority plugin so the key never lands on disk.
Federation (multi-cluster)
To trust workloads from another cluster’s SPIRE instead of running a second issuer, federate the trust domains. On each SPIRE server:
server {
# ... existing config ...
federation {
bundle_endpoint { address = "0.0.0.0" port = 8443 }
federates_with "other.trust.domain" {
bundle_endpoint_url = "https://spire.other-cluster:8443"
bundle_endpoint_profile "https_spiffe" {
endpoint_spiffe_id = "spiffe://other.trust.domain/spire/server"
}
}
}
}
Then reference the federated domain in registration entries (-federatesWith) and
add it to Shield’s SHIELD_SPIFFE_ALLOWED_WORKLOADS. Bundles refresh automatically
over the federation endpoint.
Troubleshooting
| Symptom | Likely cause | Check |
|---|---|---|
/auth/agent-token → 403 |
Identity not accepted | Is SHIELD_TRUSTED_PROXY_SECRET set on Shield and in Envoy’s X-Shield-Proxy-Token? Is SHIELD_SPIFFE_ENABLED=true? |
| Envoy TLS handshake fails | Bad/expired SVID or wrong trust domain | spire-agent api fetch x509; confirm SVID SAN domain == SHIELD_SPIFFE_TRUST_DOMAIN |
| Agent can’t fetch SVID | Not registered / socket missing | spire-server entry show; confirm the Workload API socket is mounted into the agent container |
| Works direct, blocked via Envoy | Upstream misconfig | shield_upstream in envoy.yaml points at your Shield service:port |
| Identity spoofable | IP-only trust | Set the secret, not just SHIELD_TRUSTED_PROXY_IPS (see Hard requirements) |
CI
.github/workflows/identity-bundle.yml validates the bundle on every PR that
touches it: the identity provider unit tests, docker compose config, envoy
--mode validate, and spire-server validate for both dev and prod configs. The
full boot + scripts/smoke_identity_bundle.sh runs on manual dispatch (it needs a
built Shield image + generated SVID material), as a pre-release gate.