SPIFFE Workload Identity for Spatial Services
Mutual TLS proves that two workloads share a certificate authority; it does not, on its own, say who they are in terms a spatial policy can reason about. SPIFFE closes that gap by giving every workload a structured, verifiable identity that a policy can match on — and, more importantly for a geospatial estate, one that can carry the spatial scope a workload is entitled to. This guide issues those identities, binds them to domains, and writes the policy that refuses a correctly-authenticated caller asking outside its region. It implements the authentication and scope layers of Zero-Trust Security for Spatial Endpoints within Federated Ownership & Routing Architecture, and it complements Enforcing mTLS Between Spatial Domains.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Tools | SPIRE server and agents, spire-server CLI, OPA ≥ 0.60 |
Identity issuance is separate from policy |
| Mesh | Sidecars able to consume SVIDs | mTLS must already be STRICT |
| Naming | A trust domain per estate, paths per domain and workload | The path is what policy matches on |
| Scope data | Each workload’s permitted extent, in EPSG:4326 |
Scope is data, not code |
| Access roles | platform-engineer registers; domain-owner approves scope |
Scope grants are governed |
| Environment | TRUST_DOMAIN, SPIRE_SERVER |
Exported before running |
Step-by-Step Implementation
1. Design the identity namespace before issuing anything
The SPIFFE ID is what every policy will match on for years, so its shape matters more than the issuance mechanics.
# spiffe://<trust domain>/<estate role>/<domain>/<workload>
spiffe://mesh.internal/domain/cadastre/tile-renderer
spiffe://mesh.internal/domain/cadastre/pipeline-worker
spiffe://mesh.internal/domain/logistics/router
spiffe://mesh.internal/platform/catalog
spiffe://mesh.internal/platform/gateway
Two properties are worth preserving deliberately. The domain appears as its own path segment, so a policy can grant “any workload in the cadastre domain” without enumerating workloads. And platform components live under a distinct prefix, so a policy that grants domain workloads something does not accidentally grant it to the gateway.
Verify the namespace supports the grants you expect to write, before it has any registrations in it:
# A prefix match should express "every cadastre workload" cleanly.
echo 'spiffe://mesh.internal/domain/cadastre/tile-renderer' \
| grep -qE '^spiffe://mesh\.internal/domain/cadastre/' && echo "prefix grant works"
2. Register workloads with selectors, not with secrets
# Registration binds an identity to attestable properties of the workload — its
# service account and namespace — rather than to a credential someone must copy.
spire-server entry create \
-spiffeID "spiffe://${TRUST_DOMAIN}/domain/cadastre/tile-renderer" \
-parentID "spiffe://${TRUST_DOMAIN}/spire/agent/k8s_psat/cluster-a" \
-selector "k8s:ns:cadastre" \
-selector "k8s:sa:tile-renderer" \
-selector "k8s:pod-label:app:tile-renderer" \
-ttl 3600
spire-server entry show -spiffeID "spiffe://${TRUST_DOMAIN}/domain/cadastre/tile-renderer"
Verify the selectors are specific enough that a different workload in the same namespace cannot obtain the identity:
# A pod with the namespace but not the service account must fail attestation.
kubectl -n cadastre run probe --image=curlimages/curl --serviceaccount=default \
--rm -it --restart=Never -- \
sh -c 'curl -s --unix-socket /run/spire/sockets/agent.sock http://localhost/svid || echo "denied"'
3. Attach spatial scope to the identity
This is the layer generic zero-trust guidance omits, and the one that matters most in a spatial estate.
# spatial_scope.rego — a correctly authenticated caller can still be out of scope.
package mesh.authz
import future.keywords.if
import future.keywords.in
default allow := false
# The scope registry is data, keyed by SPIFFE ID. Grants are reviewed, not coded.
scopes := data.spatial_scopes
allow if {
# 1. Authentication: a verified SPIFFE ID, not a self-asserted header.
input.caller.spiffe_id != ""
input.caller.verified == true
# 2. Authorization: default-deny, so an unlisted workload reaches nothing.
some grant in scopes[input.caller.spiffe_id]
grant.product == input.request.product
input.request.action in grant.actions
# 3. Spatial scope: the request extent must sit inside the granted extent.
within_scope(input.request.bbox, grant.extent)
}
within_scope(request_bbox, granted) if {
granted == "*" # estate-wide grant, used sparingly
}
within_scope(request_bbox, granted) if {
granted != "*"
request_bbox[0] >= granted[0]
request_bbox[1] >= granted[1]
request_bbox[2] <= granted[2]
request_bbox[3] <= granted[3]
}
# The denial reason is returned so a caller learns which layer refused them.
deny_reason := "unauthenticated" if not input.caller.verified
deny_reason := "no grant for product" if {
input.caller.verified
not scopes[input.caller.spiffe_id]
}
deny_reason := "extent outside granted scope" if {
input.caller.verified
scopes[input.caller.spiffe_id]
not allow
}
{
"spatial_scopes": {
"spiffe://mesh.internal/domain/logistics/router": [
{"product": "cadastre/parcels", "actions": ["read"],
"extent": [-2.0, 49.5, 2.0, 53.0]}
],
"spiffe://mesh.internal/platform/catalog": [
{"product": "*", "actions": ["read_metadata"], "extent": "*"}
]
}
}
Verify the scope check refuses a caller who passes authentication and authorization:
opa eval -d spatial_scope.rego -d scopes.json \
-i <(echo '{"caller":{"spiffe_id":"spiffe://mesh.internal/domain/logistics/router","verified":true},
"request":{"product":"cadastre/parcels","action":"read","bbox":[-8,45,8,58]}}') \
'data.mesh.authz.allow'
# false — the requested extent reaches outside the granted region.
4. Log identity, extent and version together
# audit.py — the record that answers the question an audit actually asks.
def audit(request, decision) -> dict:
return {
"spiffe_id": request.caller.spiffe_id,
"verified": request.caller.verified,
"product": request.product,
"version": request.resolved_version,
"extent_wkt": request.bbox_wkt, # WHAT was read, not merely that
"decision": "allow" if decision.allow else "deny",
"deny_reason": decision.reason,
"timestamp": request.received_at,
}
Configuration Reference
| Element | Value | Rationale |
|---|---|---|
| Trust domain | One per estate | Cross-estate federation is a separate decision |
| ID path | /domain/<domain>/<workload> |
Prefix grants without enumerating workloads |
| Platform prefix | /platform/<component> |
Domain grants must not reach the gateway |
| SVID TTL | 3600s |
Short enough to bound a compromised credential |
| Selectors | Namespace + service account + label | Namespace alone is too broad |
| Scope | Data, keyed by SPIFFE ID | Grants are reviewed, not coded |
| Default | Deny | An unlisted workload reaches nothing |
| Audit fields | Identity, extent, version | Extent is the field usually missing |
Common Failure Modes & Fixes
A workload obtains an identity it should not have. Root cause: selectors too broad — usually namespace only, so any pod in that namespace attests successfully. Fix: add the service account and a pod label; test with a pod that has the namespace and nothing else.
Policy passes and a caller reads outside its region. Root cause: the extent is not part of the policy input, so scope cannot be evaluated. Fix: the request extent must be a first-class field the gateway extracts, not a query parameter buried in a filter expression.
Every caller is denied after enabling default-deny. Root cause: correct and expected — the scope registry is empty. Fix: populate grants before switching the default; run in a log-only mode first to enumerate who would have been denied.
SVID rotation causes intermittent failures. Root cause: the workload caches the certificate beyond its TTL and does not reload. Fix: consume the SVID through the workload API and reload on rotation; a one-hour TTL with an application that reads at start-up fails hourly.
The audit log cannot answer which parcels a caller saw. Root cause: the extent was not recorded. Fix: capture it at decision time; the URL is aggregated away long before anyone asks.
FAQ
Is SPIFFE necessary if mTLS is already STRICT?
STRICT mTLS establishes that a peer holds a certificate from the mesh CA, which is genuinely valuable and stops an unauthenticated workload connecting. What it does not give you is an identity structured enough to write policy against — a certificate’s subject in a service mesh is typically the service account, which conflates workloads that should be distinguished and cannot express the domain a workload belongs to without string parsing. SPIFFE makes the identity a first-class, hierarchical, verifiable name, which is what turns “authenticated” into “authorized to do this, here”.
Why put spatial scope in policy data rather than in the identity?
Because scope changes and identity should not. A workload’s SPIFFE ID is stable for its lifetime and is what audit records reference; its permitted extent changes when a team’s operating region expands, a contract is signed, or a jurisdiction changes. Keeping scope as policy data keyed by identity means a scope change is a reviewed data update rather than a re-issuance, and it keeps the audit trail’s identity references stable across those changes.
How coarse should the granted extent be?
As coarse as the workload’s genuine need and no coarser, expressed as a bounding box where possible for cheap evaluation. A logistics router operating in one country should be granted that country’s envelope rather than the estate, and the review question at grant time is simply whether the requested extent matches the workload’s stated purpose. Estate-wide grants should exist — the catalog needs one — and should be few enough that listing them is a short conversation.
Does the extent check belong in the gateway or the domain?
The gateway, because it is the component that already knows the identity and the requested extent, and because a check performed once at the boundary cannot be bypassed by a path that reaches a domain another way. The domain should still refuse a request that arrives without a verified identity, as defence in depth — but duplicating the scope evaluation in every domain multiplies the places a grant change has to reach, which is how scope enforcement drifts.
What happens when a workload legitimately needs a wider extent temporarily?
Grant it explicitly, with an expiry, and record the grant as its own auditable event. Temporary scope widening is a real need — a disaster response, a one-off national analysis — and the failure mode to avoid is a permanent widening applied under time pressure that nobody revisits. Because scope lives in policy data keyed by identity, a time-bounded grant is a data change with a not_after field rather than a re-issuance, and an expired grant simply stops matching. Logging the grant, its requester and its justification alongside the access records it enables is what makes the temporary widening defensible later.
Related
- Zero-Trust Security for Spatial Endpoints — the parent topic and the three-layer model
- Enforcing mTLS Between Spatial Domains — the transport layer this builds identity on
- Federated Query and Cross-Domain Spatial Joins — why a join composes entitlements rather than inheriting them