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.

text
# 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

The identity namespace, and the grants each level makes expressibleFour levels of the SPIFFE path read bottom to top. The trust domain names the estate. The estate role separates domain workloads from platform components, so a grant to domain workloads cannot reach the gateway. The domain segment lets a policy grant every workload in one domain by prefix without enumerating them. The workload segment identifies the specific service. The annotations record what each level buys, which is why the namespace shape is worth settling before any registration exists.workloadtile-rendererthe specific servicedomaincadastreprefix grantsestate roledomain | platformkeeps grants aparttrust domainmesh.internalthe estate

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:

bash
# 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

bash
# 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:

How specific the attestation selectors need to beFour selector combinations judged on whether a different workload in the same namespace could obtain the identity. Namespace alone lets any pod in that namespace attest successfully. Namespace plus service account is much tighter and still allows any pod using that account. Adding a pod label narrows it further. All three together are the recommended combination. The final row records the check that matters: a pod carrying the namespace and nothing else must fail attestation, and testing that is the only way to know.Another pod could attestVerdictnamespace onlyany pod in ittoo broad+ service accountany pod using itbetter+ pod labelmatching pods onlybetter stillall threeeffectively noneuse this

bash
# 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.

rego
# 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
}

Three checks a request passes, and the one that generic zero trust omitsA request is checked for a verified SPIFFE identity, then against a default-deny grant for the requested product and action, then for whether its extent sits inside the granted scope. Only a request passing all three is served. Each check drops onto a rail with its own denial reason, so a caller learns which layer refused them. The third check is the one generic zero-trust guidance omits, and its absence is what turns a region-scoped grant into estate-wide access.Verified identitySVID, not a headerGrant existsdefault denyExtent in scopethe omitted layerServedand auditedverifiedgrantedin scopeunauthenticatedno grant for productextent outside scopeDenied, with the reasoncaller learns which layer

json
{
  "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:

bash
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

python
# 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.