Repository navigation
[Proposal] Mutual TLS for the gateway — client → gateway and gateway → backend #3550
Tharsanan1
started this conversation in
General
Replies: 1 comment
Decision table: what
|
| Connection certificate | Header certificate | Result | Subject | XFCC to backend |
|---|---|---|---|---|
| valid | none | 200 | client | client |
| valid | any | 200, header ignored | client | client |
| invalid | none | 401 | — | — |
| invalid | any, trust_any off |
401, header ignored | — | — |
| invalid | valid, trust_any on |
200, source: bypass |
header client | header client |
2. Client connects directly, without mTLS (no connection certificate)
trust_any |
Header certificate | Result | Subject | XFCC to backend |
|---|---|---|---|---|
| off (default) | none | 401 | — | — |
| off | any | 401, header ignored | — | — |
| on | valid | 200, source: bypass |
header client | header client |
| on | invalid | 401 | — | — |
| on | none | 401 | — | — |
3. Load balancer in front, with mTLS to the gateway, its certificate pooled as a relay entry
| Connection certificate (balancer) | Header certificate (client) | Result | Subject | XFCC to backend |
|---|---|---|---|---|
| relay, within its validity period | valid | 200, source: header, relayedBy set |
client | client |
| relay, within its validity period | invalid | 401 | — | — |
| relay, within its validity period | none | 401 | — | — |
| relay, within its validity period | more than one value | 401 | — | — |
| relay, expired, not yet valid, or unreadable | any | 401, header not examined | — | — |
relay whose authority is also in accept |
any | 200 as the balancer, header ignored | balancer | balancer |
4. Load balancer in front, with mTLS to the gateway, its certificate not pooled as a relay
| Connection certificate (balancer) | Header certificate | Result |
|---|---|---|
not in accept |
any, trust_any off |
401, header ignored |
not in accept |
valid, trust_any on |
200, source: bypass, subject = header client |
in accept |
any | 200 as the balancer, header ignored |
5. Load balancer in front, without mTLS to the gateway (plain TLS, no connection certificate)
Identical to table 2: the gateway cannot tell a balancer from any other client that presents no certificate. Only trust_any makes the header believable.
Independent of topology
| Situation | Effect |
|---|---|
forwardCertificate: false on the API |
allowed requests carry no certificate header to the backend |
Route without mtls-auth |
no evaluation; Envoy strips both certificate headers before the backend |
| Envoy sent no TLS attributes | 401, never treated as "not required" |
| Client-CA pool empty | listener stops requesting certificates; every mtls-auth route denies, one warning per pool version |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
[Proposal] Mutual TLS for the gateway — client → gateway and gateway → backend
Problem
The gateway has no data-plane mTLS in either direction today. This proposes mTLS in both directions:
including when a load balancer terminates TLS in front of the gateway and relays the certificate
in a header.
requires one, with per-upstream trust.
The two are not symmetric. Inbound is part transport, part policy: Envoy negotiates and validates,
a policy decides per API. Outbound is pure transport: a client certificate is presented on a pooled
connection before any request exists, so no policy can touch it, and
upstream.auth.typedoes notgain an
mtlsvalue.mTLS is authentication only. The new
mtls-authpolicy writes anAuthContextand nothingelse — no application id, nothing
subscription-validationconsumes. An API that needs bothattaches both, exactly as for a JWT-protected API today.
Inbound
Listener
The shared HTTPS listener gains a client-CA validation context derived from deployed APIs: when
any API attaches
mtls-auththe listener requests a certificate; when the last one is removed itstops.
require_client_certificate: false, so callers of non-mTLS APIs are unaffected. There is nogateway-level switch.
Envoy validates every presented certificate but is configured with
trust_chain_verification: ACCEPT_UNTRUSTED, so it never drops the connection over a badcertificate. The verdict reaches the policy engine as
connection.peer_certificate_validalongsidethe certificate. Every certificate outcome is therefore an HTTP outcome: an invalid certificate gets
the same uniform 401, analytics record and access-log line as a certificate from the wrong
authority. Only pre-certificate TLS failures (protocol or cipher mismatch) still die at the
handshake, and those are unrelated to mTLS. TLS session resumption is switched off while the listener asks for a certificate, so a resumed connection is judged exactly like a new one.
The pool, and the policy that selects from it
Two tiers. The admin curates a pool of client authorities through the existing certificates
endpoint, told where the certificate lands:
usageisupstream(default — backend trust, as today),client(a client authority) oridentity(a certificate the gateway presents, see Outbound). A self-signed client certificate goesinto the pool as its own one-member authority.
The developer selects from the pool by attaching the policy. Nothing else changes in the API:
Entries are tried in order, first match wins.
cais always required and is verifiedcryptographically — a signature and path check back to the named pool entry, never an
issuer-DN compare, because the handshake proved only that the certificate chains to something in
the pool. Adding an authority to the pool grants no access to any API; only an
acceptlist does.Every parameter must have its declared shape: a narrowing given as a scalar, an empty
match, or anentry that is not an object is refused at deploy, never read as "no narrowing".
Output:
AuthContext{Authenticated: true, AuthType: "mtls", Subject: <matched SAN or DN>, Issuer: <pool entry name>, CredentialID: <thumbprint>}.acceptandnotAfterare re-evaluatedon every request, so removing an entry, a SAN or a thumbprint takes effect on the caller's next
request; pool changes reach new handshakes only.
Behind a load balancer: the certificate arrives in a header
When a proxy terminates TLS, the client certificate reaches the gateway only as a header the proxy
sets. The same policy handles it; the API YAML does not change. What changes is how the header
is trusted, because a header is text anyone can send:
matchso that only the proxy's own certificate under a shared authority can relayPOST /certificates { "usage": "client", "role": "relay", "match": {…}, … }trust_any = trueinconfig.tomlfalse, warns at startup and on every deploy while onHeader mode is on when a relay entry exists or
trust_anyis true, and off otherwise — there is noenabledkey. On a route withmtls-auththe policy decides in this order:acceptentry, the caller isauthenticated as the connection and the header is ignored. An API that accepts the load
balancer's authority therefore authenticates the load balancer; an API that accepts only partner
authorities never does.
accept— butonly when the connection chains to a relay entry, or under
trust_any. The policy parses,date-checks and chains the relayed certificate to the pool itself, since no handshake the
gateway saw validated it.
setting rescues it: a caller who identified itself with a bad certificate does not get a second
chance through a header.
A partner connecting directly who sends a header is therefore authenticated as themselves; the
header is ignored. A relay entry cannot itself be named in an
accept; to accept the load balanceras a caller, its authority is pooled a second time as an ordinary client entry. The header is
deleted before every backend unless the policy believed it, and an API
can keep both certificate headers away from its backend with
forwardCertificate: false. Theheader name may not be one Envoy owns or a connection header; those are refused at startup.
Outbound
A
tlsblock on anupstreamDefinitionsentry — never on an inlineupstream.main.url, and not anew
upstream.auth.type:Every field is optional.
tlsbelongs to the definition and is shared by its targets; two backendsneeding different identities are two definitions, and
dynamic-endpointselects between them peroperation. Identities are uploaded once by the admin, stored encrypted, delivered to Envoy over SDS
as
Secret_TlsCertificate, rotated in place withPUT, and never returned by any read:A definition with a
tlsblock gets its own Envoy cluster, so two APIs reaching the same backendwith different identities or trust never share a connection pool. A backend TLS failure is Envoy's
ordinary 503; the reason goes to the access log. An identity's key must be RSA of at least 2048
bits or an ECDSA P-256, P-384 or P-521 key, uploaded as exactly one key block with a leaf-first
chain; anything else is refused at upload rather than failing later on a backend connection.
Two existing weaknesses are fixed on the way: the listener's private key used to travel inline in
the listener resource and now moves to SDS; and the certificate store, which backs every SDS
secret, is always present — a load failure is a startup failure rather than a silent loss of
verification.
Observability
Nothing new is invented; the feature rides the existing rails. The JSON access log gains the TLS
fields (
sni,tlsVer,peerSubj,peerFp,upTlsFail). Themtls-authpolicy span carries theresult, the reason on a deny, the matched entry, whether the certificate came from the handshake or
a relay, and the standard
tls.client.*attributes — never certificate material. A deny counts onthe generic
policy_executions_total{status="denied"}; the controller exposes certificate counts perusage and each certificate's expiry, and warns thirty days ahead of expiry in the listing, the
log and the gauge.
Interfaces
POST/GET/DELETE /certificatesusage,role,match,?usage=; omit the new fields and behaviour is unchangedPOST /certificateswithusage: identity,PUT /certificates/{id}PUTrotates an identity; no read ever returns the keyData model: additive, defaulted or nullable columns on the shipped
certificatestable (usage,role,match_json,private_key_ciphertext,key_algorithm), all with a guarded per-dialectALTER; no new table. No other TOML besides the header block above.Guarantees the tests defend
nilnever means "not required".trust_anyposture; a connection whose certificate was rejected is never rescued by a header.usagefilters and asserted disjoint.X-Forwarded-Client-Certreaches a backend only on routes whose chain hasmtls-auth; every otherroute strips it. The relayed-certificate header reaches a backend only when the policy believed it.
acceptnaming a missing, relay orupstreamentry;tls.identitymissing) is refused at deploy. Deleting an entry an API names is refused with 409.
or a private key pasted into a certificate field is refused, never ignored.
inline_bytes, never in a read response, config dump or log.All reactions