Enforcing mTLS Between Spatial Domains
Turning on STRICT mutual TLS between the workloads that make up a federated geospatial estate, so a cross-domain tile or vector call can only succeed when the caller presents a valid, verifiable workload identity — and is refused at transport otherwise. This is the concrete operational procedure behind the Zero-Trust Security for Spatial Endpoints model within the broader Federated Ownership & Routing Architecture: you flip Istio’s peer authentication to STRICT for each domain namespace, verify with istioctl that no plaintext path remains, and require the mTLS-derived principal in the AuthorizationPolicy so an unauthenticated caller can never reach a cadastral feature store. It assumes the domain boundaries you are locking down were already drawn per Spatial Domain Boundary Design.
Prerequisites
| Requirement | Value / Assumption | Notes |
|---|---|---|
| Service mesh | Istio >= 1.20 with sidecar injection enabled |
istio-injection=enabled on each domain namespace |
| CLI tooling | istioctl matching the control-plane version, kubectl |
Version skew warns on istioctl authn tls-check |
| Identity plane | SPIFFE SVIDs issued by Istio citadel or SPIRE | X.509 SVID TTL ≤ 1h, auto-rotated |
| Namespaces | cadastral, geocode, gateway (one per domain) |
Each workload runs under a dedicated service account |
| Access role | mesh-security-admin (RBAC) |
Required to apply PeerAuthentication / AuthorizationPolicy |
| CRS contract | EPSG:4326 for feature bbox, EPSG:3857 for tile delivery |
Unchanged by mTLS; validated downstream |
| Baseline mode | Mesh currently in PERMISSIVE mode |
Migrate to STRICT without dropping in-flight calls |
Step-by-Step Implementation
Migrate one domain at a time. Enabling STRICT globally before every workload has a sidecar and a valid SVID will hard-fail plaintext callers, so the sequence below tightens transport first, verifies, then requires identity in authorization. Each step ends with a verification command.
1. Confirm sidecar injection and current mTLS posture
Before changing anything, confirm every workload in the domain namespace carries an Istio proxy and see whether traffic is already mutually authenticated. A pod without a sidecar cannot present an SVID and will be locked out the moment STRICT is applied.
kubectl get pods -n cadastral \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].name}{"\n"}{end}'
# every pod must list an "istio-proxy" container
Verify: inspect the effective TLS settings the mesh reports for the workload.
istioctl x describe pod -n cadastral $(kubectl get pod -n cadastral \
-l app=wfs-feature-engine -o jsonpath='{.items[0].metadata.name}')
# look for "mTLS: PERMISSIVE" in the current state before you tighten it
2. Apply STRICT PeerAuthentication to the domain namespace
Scope STRICT mutual TLS to the single domain namespace so the blast radius is one domain, not the mesh. STRICT rejects any plaintext peer at transport — the connection is reset before HTTP is parsed.
# strict-cadastral.yaml — require verified mTLS on every inbound edge of the
# cadastral domain. Namespaced, so other domains stay PERMISSIVE until migrated.
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: cadastral-mtls-strict
namespace: cadastral
spec:
mtls:
mode: STRICT
kubectl apply -f strict-cadastral.yaml
Verify: confirm the policy is active and scoped to the intended namespace.
kubectl get peerauthentication -n cadastral
istioctl authn tls-check \
$(kubectl get pod -n geocode -l app=resolver -o jsonpath='{.items[0].metadata.name}').geocode \
wfs-feature-engine.cadastral.svc.cluster.local
# STATUS must read OK and SERVER must read STRICT for the cadastral destination
3. Require the mTLS identity in the AuthorizationPolicy
STRICT transport proves the channel is encrypted and the peer is verified; it does not by itself restrict which verified identity may call. Add an allow-list AuthorizationPolicy keyed on the SPIFFE principal so only the gateway and named sibling domains reach the endpoint. Unmatched requests fall through to an implicit 403 DENY.
# authz-cadastral.yaml — only these workload identities, over verified mTLS,
# may GET the OGC API endpoints. Default action for a non-match is DENY.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: cadastral-endpoint-authz
namespace: cadastral
spec:
selector:
matchLabels:
app: wfs-feature-engine
action: ALLOW
rules:
- from:
- source:
principals:
- "cluster.local/ns/gateway/sa/geospatial-gateway"
- "cluster.local/ns/routing/sa/cross-domain-router"
to:
- operation:
methods: ["GET"]
paths: ["/collections/*", "/tiles/*", "/tileMatrixSets/*"]
kubectl apply -f authz-cadastral.yaml
Verify: an identity that is not on the allow-list must be denied even over valid mTLS.
kubectl exec -n analytics deploy/adhoc-client -c istio-proxy -- \
curl -s -o /dev/null -w "%{http_code}\n" \
http://wfs-feature-engine.cadastral:8080/collections/parcels/items
# expect 403 — verified mTLS, but principal not in the allow-list
4. Confirm an authorized cross-domain call still succeeds
Prove the policy admits legitimate traffic. A call from the cross-domain router’s identity, over mTLS, to an allow-listed path must return 200.
kubectl exec -n routing deploy/cross-domain-router -c istio-proxy -- \
curl -s -o /dev/null -w "%{http_code}\n" \
"http://wfs-feature-engine.cadastral:8080/collections/parcels/items?bbox=-1.2,52.6,-1.0,52.8&crs=EPSG:4326"
# expect 200 — allow-listed principal, verified mTLS, permitted path
Verify: confirm the request was mutually authenticated end to end, not silently downgraded.
istioctl authn tls-check \
$(kubectl get pod -n routing -l app=cross-domain-router -o jsonpath='{.items[0].metadata.name}').routing \
wfs-feature-engine.cadastral.svc.cluster.local
# CLIENT and SERVER must both report STRICT / OK
Configuration Reference
| Field | Scope | Required value | Effect |
|---|---|---|---|
spec.mtls.mode |
PeerAuthentication |
STRICT |
Rejects plaintext peers at transport |
metadata.namespace |
PeerAuthentication |
Domain namespace, e.g. cadastral |
Scopes STRICT to one domain, bounding blast radius |
spec.action |
AuthorizationPolicy |
ALLOW |
Turns the rule set into an allow-list; non-match → DENY |
source.principals |
Authz rule | SPIFFE ID, e.g. cluster.local/ns/gateway/sa/geospatial-gateway |
Only these identities pass |
operation.methods |
Authz rule | ["GET"] |
Restricts reads; writes granted separately |
operation.paths |
Authz rule | /collections/*, /tiles/*, /tileMatrixSets/* |
OGC API surface exposed to the caller |
selector.matchLabels |
Authz rule | app: wfs-feature-engine |
Binds the policy to the domain workload |
Common Failure Modes & Fixes
All cross-domain calls fail with connection reset immediately after applying STRICT.
Root cause: a caller workload has no sidecar (not injected) or no valid SVID, so it cannot present a client certificate to the STRICT peer. Fix: confirm injection and restart the caller — kubectl label ns <caller-ns> istio-injection=enabled --overwrite && kubectl rollout restart deploy/<caller> -n <caller-ns>.
istioctl authn tls-check reports CONFLICT.
Root cause: a DestinationRule sets tls.mode: DISABLE (or SIMPLE) while PeerAuthentication requires STRICT, so client and server disagree on transport. Fix: align the client side — set the DestinationRule trafficPolicy.tls.mode to ISTIO_MUTUAL for that host.
Authorized identity gets 403 on a path that should be allowed.
Root cause: the requested path does not match any operation.paths glob (for example /tileMatrixSets without the trailing segment), so the allow-list does not match and the request is denied. Fix: dump the effective policy and align the path — kubectl get authorizationpolicy -n cadastral -o yaml | grep -A3 paths.
STRICT applied but tls-check still shows PERMISSIVE for the destination.
Root cause: a narrower workload-scoped PeerAuthentication (with a selector) is overriding the namespace-wide policy. Fix: list all peer policies in the namespace and remove or align the conflicting selector-scoped one — kubectl get peerauthentication -n cadastral.
Health checks start failing after enabling STRICT.
Root cause: kubelet liveness/readiness probes originate outside the mesh and cannot present an SVID, so STRICT resets them. Fix: exclude the probe port from mTLS or use Istio probe rewrite — annotate the pod with sidecar.istio.io/rewriteAppHTTPProbers: "true".
FAQ
Does STRICT mTLS on its own stop a stolen token from reaching the cadastral domain?
No — that is why step 3 exists. STRICT guarantees the transport is encrypted and the peer identity is cryptographically verified, but a valid identity from an unrelated domain is still a valid peer at transport. The AuthorizationPolicy allow-list is what turns “verified caller” into “verified and entitled caller,” denying any SPIFFE principal that is not explicitly granted the OGC API path.
Why migrate namespace-by-namespace instead of enabling STRICT mesh-wide?
Enabling STRICT globally fails every plaintext caller at once — including any workload not yet injected with a sidecar or any external probe. Scoping PeerAuthentication to one domain namespace bounds the blast radius to a single domain, so you can verify with istioctl authn tls-check and roll back one namespace without touching the rest of the mesh.
How do I confirm a call was actually mutually authenticated and not downgraded?
Run istioctl authn tls-check <client-pod>.<ns> <server-fqdn> and confirm both CLIENT and SERVER report STRICT with STATUS OK. A curl returning 200 proves reachability but not the transport mode; only the tls-check output (or the request’s X-Forwarded-Client-Cert header at the destination) confirms mTLS end to end.
What happens to in-flight requests during the switch to STRICT?
With the mesh starting in PERMISSIVE mode, existing mTLS connections keep working and only new plaintext connections are refused once STRICT applies, so already-injected workloads see no drop. The risk is exclusively workloads without a sidecar or a fresh SVID — which is why step 1 verifies injection before you tighten anything.
Do these mTLS changes affect the CRS or bbox contract of the endpoint?
No. mTLS operates at the transport and identity layer; it does not touch the request payload. The EPSG:4326 bbox validation and CRS normalization still happen in the endpoint’s own contract enforcement downstream, exactly as before — mTLS only decides whether the connection is allowed to carry that payload at all.
Related
- Zero-Trust Security for Spatial Endpoints — the parent model this procedure implements
- API Gateway Mapping for GIS Services — the ingress edge that terminates mTLS before internal hops
- Schema Contracts for Vector/Tile Data — payload validation applied once identity is verified
- Spatial Domain Boundary Design — how the domains you are isolating were drawn
- Federated Ownership & Routing Architecture — up one level to the routing architecture overview