Skip to content

Network hardening

Everything on this page is required for production deployment: the enrollment and request flows work with the documented configuration, and these controls are what make the same flows safe to rely on in production. The adapter only enforces the boundary if the network makes the adapter the only path. This page hardens the enforced traffic flow:

flowchart LR
  C["Caller"] --> G["Gateway / ingress"]
  G -->|"protected routes only"| A["trustplane-adapter"]
  A -->|"verified only"| U["Upstream API"]
  C -. "must be denied" .-> U
  C -. "must be denied" .-> CT["Control (direct)"]

Three properties must hold at the network layer, independently of anything the adapter verifies:

  1. The upstream accepts traffic only from the adapter. Any other path lets callers skip verification entirely.
  2. The adapter can reach only what it needs. Restricted egress limits what a compromised adapter (or a compromised bundle-refresh dependency) can touch.
  3. The gateway routes protected traffic only to the adapter, never directly to the upstream. A single leftover direct route defeats the whole boundary.

Remember the two-plane distinction: after enrollment, request authorization is possession of the derived private key plus key status and route policy — enrollment does not pin later requests to the original workload. Network controls like the ones on this page are exactly the “additional network controls” that limit where a stolen or exported derived key is usable. See OIDC JWKS enrollment runbook for the enrollment-plane view.

  • A running adapter deployment with a working caller → gateway/ingress → adapter → upstream path (see Deployment overview and Gateway integration patterns). Harden a working path; do not debug routing and policy at the same time.
  • Kubernetes namespaces and pod labels for the adapter and the upstream. Every namespace and label below is a placeholder — take the real values from your own manifests or Helm values, for example helm get values <release> or kubectl get pods -n <namespace> --show-labels.
  • A CNI that actually enforces NetworkPolicy (see the caveat below).
  • Permission to create NetworkPolicy objects in both namespaces, and to run short-lived test pods for verification.

Placeholders used throughout — replace all of them:

Placeholder Meaning Where the real value comes from
<upstream-namespace> Namespace running the upstream API Your upstream deployment manifests
app: <upstream-api-label> Pod label selecting the upstream API pods kubectl get pods -n <upstream-namespace> --show-labels
<adapter-namespace> Namespace running trustplane-adapter Your Helm release namespace
app.kubernetes.io/name: trustplane-adapter Adapter pod label Rendered chart output (helm template)
8080 Upstream application port Upstream Service/container spec
8081 Adapter listen port adapter.port in your Helm values
<control-endpoint-cidr> IP range for Control / bundle refresh Your Control deployment’s published addresses
<jwks-endpoint-cidr> IP range for configured JWKS/issuer endpoints Your issuer’s published addresses
<telemetry-endpoint-cidr> IP range for telemetry/audit sinks Your observability platform

Upstream: default-deny ingress, allow only the adapter

Section titled “Upstream: default-deny ingress, allow only the adapter”

Required for production deployment. A NetworkPolicy that selects the upstream pods is an allow-list: once any policy selects a pod for Ingress, everything not explicitly allowed is denied. One policy therefore implements both default-deny and the single adapter allowance:

# All namespace names and labels below are PLACEHOLDERS — replace with your values.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: upstream-allow-adapter-only
namespace: <upstream-namespace> # placeholder
spec:
podSelector:
matchLabels:
app: <upstream-api-label> # placeholder: your upstream pod label
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: <adapter-namespace> # placeholder
podSelector:
matchLabels:
app.kubernetes.io/name: trustplane-adapter # placeholder: adapter pod label
ports:
- protocol: TCP
port: 8080 # placeholder: upstream application port

Expected state after applying: the upstream Service still resolves cluster-wide, but only adapter pods can complete a connection to it. Combining namespaceSelector and podSelector in one from entry means “adapter-labeled pods in the adapter namespace” — do not split them into two list items, which would mean “any pod in the adapter namespace OR any adapter-labeled pod anywhere”.

Required for production deployment. The adapter needs a short list of destinations and nothing else:

  • the upstream API;
  • cluster DNS;
  • Control, for signed bundle refresh (see Bundle refresh and Control-signed bundles);
  • the configured JWKS/issuer endpoints, when the deployment fetches issuer material at runtime;
  • telemetry and audit dependencies, if the adapter ships events off-pod.
# All namespace names, labels, and CIDRs below are PLACEHOLDERS — replace with your values.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: trustplane-adapter-restrict-egress
namespace: <adapter-namespace> # placeholder
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: trustplane-adapter # placeholder: adapter pod label
policyTypes:
- Egress
egress:
# Cluster DNS
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Upstream API
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: <upstream-namespace> # placeholder
podSelector:
matchLabels:
app: <upstream-api-label> # placeholder
ports:
- protocol: TCP
port: 8080 # placeholder: upstream application port
# Control / bundle refresh, JWKS/issuer endpoints, telemetry (HTTPS)
- to:
- ipBlock:
cidr: <control-endpoint-cidr> # placeholder
- ipBlock:
cidr: <jwks-endpoint-cidr> # placeholder
- ipBlock:
cidr: <telemetry-endpoint-cidr> # placeholder
ports:
- protocol: TCP
port: 443

Required for production deployment. Audit every route, listener, virtual host, and location/route object on the gateway: protected traffic must target the adapter Service only. There must be no route — including default/fallback routes, legacy routes kept “temporarily”, or debug listeners — that reaches the upstream directly. The per-gateway shapes are in Gateway integration patterns; this page is the network-level backstop for them. Even with a correct gateway config, keep the upstream ingress policy above in place: gateway configuration drifts, and the NetworkPolicy catches the drift.

For standing up the public hostname, certificate, and DNS in front of the gateway, see Gateway, certificates, and DNS.

Required for production deployment. With an AWS ALB using target-type: ip, the load balancer sends traffic straight to pod IPs. NetworkPolicy governs in-cluster paths (pod-to-pod, and what pods the ALB targets accept); ALB security groups and VPC routing/firewalling govern external reachability. Both layers are needed:

  • NetworkPolicy cannot stop an internet client from reaching the ALB — the ALB’s security groups and listener rules do that.
  • ALB security groups cannot stop an in-cluster pod from dialing the upstream — the NetworkPolicy does that.

Verify each layer with a check that exercises it; passing one layer says nothing about the other.

Required for production deployment. Run all four checks. They use short-lived test pods (kubectl run --rm) and read only HTTP status codes — no secrets, no response bodies. curl prints 000 when the connection itself fails (blocked, timed out, or reset), which is the “denied” signal.

Terminal window
# 1. Arbitrary pod → upstream (expect denied)
kubectl run tp-netcheck --rm -i --restart=Never -n default \
--image=curlimages/curl -- \
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \
http://<upstream-service>.<upstream-namespace>.svc.cluster.local:8080/
# 2. Adapter identity → upstream (expect allowed)
kubectl run tp-netcheck-adapter --rm -i --restart=Never -n <adapter-namespace> \
--labels=app.kubernetes.io/name=trustplane-adapter \
--image=curlimages/curl -- \
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \
http://<upstream-service>.<upstream-namespace>.svc.cluster.local:8080/
# 3. Gateway → adapter → upstream (expect the adapter to answer and deny the unsigned request)
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \
https://<gateway-hostname>/<protected-route>
# 4. Direct-to-Control bypass (expect denied)
kubectl run tp-netcheck-control --rm -i --restart=Never -n default \
--image=curlimages/curl -- \
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 \
https://<control-internal-address>/

Check 2 borrows the adapter’s network identity by carrying its pod label in its namespace — that is the point of the check, and also why the pod must be short-lived (--rm) and why the label values must exactly match the adapter’s.

# Check Expected result What it proves
1 Arbitrary pod → upstream 000 (connection blocked/timed out) Default-deny ingress is enforced; callers cannot bypass the adapter in-cluster
2 Adapter → upstream An HTTP status from the upstream (for example 200 on a health path, or 404 on /) The allow rule admits exactly the adapter identity; the data path still works
3 Gateway → adapter → upstream 401 or 403 from the adapter The public path terminates at the adapter, which denies an unsigned request — verification is in the path
4 Arbitrary pod → Control directly 000 (connection blocked/timed out) Control is reachable only through its fronting proxy, a precondition for the trusted-proxy section below

Expected safe failure states: check 1 or 4 returning an HTTP status code means the deny is not enforced — stop and fix the policy (or the CNI) before proceeding; nothing has been broken, the boundary just is not up yet. Check 2 returning 000 means the allow rule’s labels/namespace do not match the real adapter pods — the adapter itself is likely also cut off, so fix or roll back the egress/ingress policy promptly. Check 3 returning 000 is a gateway routing or DNS/TLS problem, not a policy success.

Required for production deployment. Control derives the client address of each request from forwarded headers (X-Forwarded-For and friends) only when the request arrives from a configured trusted forwarded-address CIDR. Order matters:

  1. First block direct access to Control (verification check 4 above, plus the equivalent external controls). While anything can reach Control directly, any client can write its own X-Forwarded-For.
  2. Only then configure the trusted forwarded-address CIDRs to the stable address range of the proxy/gateway tier that legitimately fronts Control.

Rules for the CIDR value:

  • Never 0.0.0.0/0. That trusts forwarded headers from everyone, which is equivalent to letting every client choose its own identity.
  • Use a stable proxy identity or range — the gateway tier’s allocated subnet, NAT/egress range, or load-balancer range from your platform’s configuration.
  • Do not use an observed ephemeral pod IP. A pod IP seen in logs today is reassigned to an arbitrary pod tomorrow; a trusted-proxy entry built from it either breaks silently on reschedule or, worse, ends up trusting an unrelated workload.

Why this matters: Control applies per-client rate limiting keyed on the derived client address (enrollment submission and polling are rate-limited per client — see Enrollment troubleshooting). A spoofable forwarded address lets one caller either evade its own limits or exhaust another client’s bucket, and it poisons the client addresses recorded in audit evidence. Trusted-proxy configuration is what makes “per-client” mean the client and not whatever the client claims.

NetworkPolicy objects are additive and independent of the adapter, bundles, keys, and Control state. Rolling back is deleting the specific policies:

Terminal window
kubectl delete networkpolicy upstream-allow-adapter-only -n <upstream-namespace>
kubectl delete networkpolicy trustplane-adapter-restrict-egress -n <adapter-namespace>

That restores the namespace’s previous (typically open) connectivity and touches nothing else: no bundle, key, enrollment, or Control-side change is involved, so there is nothing else to recover. The trusted-proxy CIDR configuration in Control is a separate control with its own change history — revert it in Control if the fronting proxy range changes. Note that deleting the upstream ingress policy re-opens the bypass path; treat that as an emergency measure with a ticket to restore, not a steady state.