Post-quantum mTLS in a Kuma mesh, the secure way
Kuma says every service-to-service call is mTLS, and it is. Look at the sidecar counters and you find TLS 1.2 with classical X25519 on every hop, which is fine today and readable later by anyone recording your traffic.
The short answer
Set MeshTLS tlsVersion to TLS13, then add a MeshProxyPatch that sets ecdhCurves to MLKEM1024, X25519MLKEM768, X25519 on both the inbound listener and the outbound cluster TLS contexts. Both sides must offer ML-KEM: patching only the server leaves every connection on X25519. Verify with the ssl.curves counters of both sidecars.
On this page
What goes wrong
A Kuma mesh encrypts every call between sidecars with mTLS. Kuma chooses the certificates; Envoy, inside each sidecar, chooses the TLS version and the key exchange. With Kuma's defaults, a sidecar-to-sidecar connection in Kuma 2.14 negotiates TLS 1.2 and X25519. Service-to-service traffic often carries the most sensitive data you have, and all of it is recorded-now, decrypt-later material.
Kuma's MeshTLS policy fixes the version but has no field for the key exchange group. The only way to set it is a MeshProxyPatch, a low-level patch on the Envoy configuration Kuma generates.
That patch has a trap. A TLS client offers groups, and the server picks one of them. The inbound listener is the server side; the outbound cluster is the client side. Patch only the inbound side, which is what most people do first, and the client still offers only classical groups. Nothing changes, and nothing tells you.
What the docs say
This policy enables Kuma to configure TLS mode, ciphers and version.
Source: Kuma docs, MeshTLS
The MeshProxyPatch provides configuration options for low-level Envoy resources that Kuma policies do not directly expose.
Source: Kuma docs, MeshProxyPatch
If you use JSONPatch, remember to always use camelCase instead of snake_case in path parameter even though you see snake_case in Envoy Config Dump.
Source: Kuma docs, MeshProxyPatch
If not specified, the default curves will be used.
Source: Envoy docs, TlsParameters ecdh_curves
The MeshTLS page does not mention key exchange groups at all. The
MeshProxyPatch page shows how to patch tlsParams on a cluster, but not
that the listener side needs the same patch.
The secure configuration
Kubernetes form (in universal mode the spec is identical, with type,
name and mesh at the top level instead of metadata):
# TLS 1.3 for every mesh connection: ML-KEM groups exist only in TLS 1.3.
apiVersion: kuma.io/v1alpha1
kind: MeshTLS
metadata:
name: tls13
namespace: kuma-system
labels:
kuma.io/mesh: default
spec:
targetRef:
kind: Mesh
rules:
- default:
tlsVersion:
min: TLS13
max: TLS13
---
# Server side: the TLS context of every inbound listener.
apiVersion: kuma.io/v1alpha1
kind: MeshProxyPatch
metadata:
name: pq-inbound
namespace: kuma-system
labels:
kuma.io/mesh: default
spec:
targetRef:
kind: Mesh
default:
appendModifications:
- listener:
operation: Patch
match:
origin: inbound
jsonPatches:
- op: add # "add" replaces tlsParams if MeshTLS already set it
path: /filterChains/0/transportSocket/typedConfig/commonTlsContext/tlsParams
value:
tlsMinimumProtocolVersion: TLSv1_3
tlsMaximumProtocolVersion: TLSv1_3
ecdhCurves: [MLKEM1024, X25519MLKEM768, X25519]
---
# Client side: the TLS context of every outbound cluster.
apiVersion: kuma.io/v1alpha1
kind: MeshProxyPatch
metadata:
name: pq-outbound
namespace: kuma-system
labels:
kuma.io/mesh: default
spec:
targetRef:
kind: Mesh
default:
appendModifications:
- cluster:
operation: Patch
match:
origin: outbound
jsonPatches:
- op: add
path: /transportSocket/typedConfig/commonTlsContext/tlsParams
value:
tlsMinimumProtocolVersion: TLSv1_3
tlsMaximumProtocolVersion: TLSv1_3 # Envoy's client default max is TLS 1.2
ecdhCurves: [MLKEM1024, X25519MLKEM768, X25519]MLKEM1024 comes first, so two patched sidecars agree on ML-KEM-1024 (NIST level 5). X25519 stays last so a sidecar that has not received the patch yet can still connect during the rollout.
Roll out to one namespace first with targetRef: {kind: MeshSubset, tags: {...}} instead of Mesh, check the counters, then widen.
Prove it
The test in secure-tests/post-quantum-mtls-kuma-mesh/run.sh runs Kuma
2.14.5 in universal mode on Docker: a control plane and two workloads, web
calling backend through their sidecars, with builtin mTLS. After each step
it restarts both sidecars, sends a request, and reads Envoy's TLS counters
for the backend's inbound listener and the web's outbound cluster through
the control plane API (kumactl inspect dataplane <name> --type=stats
returns the same counters).
== 1. Builtin mTLS, Kuma defaults
backend, inbound listener (server side):
ssl.curves.X25519: 2
ssl.versions.TLSv1.2: 2
web, outbound cluster to backend (client side):
ssl.curves.X25519: 2
ssl.versions.TLSv1.2: 2
== 2. + MeshTLS tlsVersion TLS13
backend, inbound listener (server side):
ssl.curves.X25519: 2
ssl.versions.TLSv1.3: 2
web, outbound cluster to backend (client side):
ssl.curves.X25519: 2
ssl.versions.TLSv1.3: 2
== 3. + MeshProxyPatch on inbound listeners only
backend, inbound listener (server side):
ssl.curves.X25519: 1
ssl.versions.TLSv1.3: 1
web, outbound cluster to backend (client side):
ssl.curves.X25519: 1
ssl.versions.TLSv1.3: 1
== 4. + MeshProxyPatch on outbound clusters too
backend, inbound listener (server side):
ssl.curves.MLKEM1024: 2
ssl.versions.TLSv1.3: 2
web, outbound cluster to backend (client side):
ssl.curves.MLKEM1024: 2
ssl.versions.TLSv1.3: 2
== Envoy in the Kuma data plane image
1.38.4-contrib/Modified/RELEASE/BoringSSLStep 3 is the trap: the server offers ML-KEM, the client does not, and the connection stays on X25519. Step 4 is the fix.
On a Kubernetes cluster:
kumactl inspect dataplane <backend-pod>.<namespace> --type=stats | grep -E "_[0-9]+\.ssl\.curves\."
kumactl inspect dataplane <web-pod>.<namespace> --type=stats | grep -E "^cluster\..*\.ssl\.curves\."What you should see: ssl.curves.MLKEM1024 counters growing on both sides
for new connections. Existing connections keep their old key exchange until
they close; restart a test workload to see the change at once.
Mistakes people make
Patching only the server side
The inbound listener picks from what the client offers. Until the outbound clusters offer ML-KEM, every connection stays classical. Patch both, and check both sets of counters.
Forgetting the client's TLS 1.2 ceiling
Envoy's upstream (client) TLS contexts default to a maximum of TLS 1.2. Set
tlsMaximumProtocolVersion: TLSv1_3 in the outbound patch, or rely on
MeshTLS setting it, but never set only the minimum.
Assuming filter chain 0 in permissive mode
In strict mTLS the inbound listener has one filter chain, and the patch path
uses index 0. Permissive mode may add chains for plaintext traffic. Check the
config dump (kumactl inspect dataplane <name> --type=config-dump) for the
index of the TLS chain before you patch, or run strict mTLS.
Outbound clusters without TLS
A JSON patch needs its path to exist. Clusters Kuma generates without a TLS transport socket, for example to external services reached in plain text, do not have it. Check the config dump of a proxy that uses them after you apply the patch, and scope the patch if needed.
Treating the patch as set-and-forget
MeshProxyPatch edits generated Envoy config, which can change shape between Kuma releases. Re-run the counter check after every Kuma upgrade.
Forgetting the control plane channel
The sidecar's own connection to the Kuma control plane (the ads_cluster)
is outside MeshProxyPatch. In this test it negotiated TLS 1.2 with X25519.
It carries configuration and certificates, not application data, but list it
in your inventory.
Checklist
- Apply MeshTLS with
tlsVersionmin and maxTLS13. - Apply the inbound MeshProxyPatch with
ecdhCurves: [MLKEM1024, X25519MLKEM768, X25519]. - Apply the outbound MeshProxyPatch with the same groups and a TLS 1.3 maximum.
- Roll out with
MeshSubsetfirst, then the whole mesh. - Check
ssl.curves.MLKEM1024on both the inbound listener and the outbound cluster. - Check permissive-mode listeners and plain-text outbound clusters in the config dump.
- Re-check after every Kuma upgrade.
mTLS in a mesh is a conversation with two sides. Teach both of them the new words, or they keep talking in the old ones.
H2-CPQE
Learn it on a live range
Post-quantum TLS, in Edge and Post-Quantum Networking: a real host in your browser, and every objective checked on the machine.
Start freeThe Dome
Want it run for you?
The Dome puts post-quantum TLS, a WAF that blocks, signed DNS and a zero-trust mesh in front of your application. Tell us what you run.
See the Dome