Securing frontend client traffic using mutual TLS
Learn how to configure mutual TLS (mTLS) between clients and NGINX Gateway Fabric to validate both the client and Gateway.
This guide shows how to configure client-to-Gateway validation using the Gateway’s Listener HTTPS (HTTP over TLS) settings together with the Gateway’s Frontend TLS settings. The example demonstrates how to validate client certificates and present the Gateway’s certificate to the client. This ensures communication between the client and the Gateway is protected with mutual TLS, and only authorized requests are processed and sent to the backend.
With Frontend TLS, the Gateway validates incoming client certificates against one or more CA certificates. You can configure validation at two levels:
- Default: Applies frontend validation to all HTTPS listeners on the Gateway.
- Per-port: Overrides the default frontend validation for specific listener ports.
Both Default and Per-port can be configured with one of two validation modes:
- AllowValidOnly (default): When set, a valid client certificate must be presented. Connections without a valid certificate are rejected.
- AllowInsecureFallback: When set, all connections with either a valid or invalid certificate are allowed, as well as no certificate.
CA certificates can be stored in either a Secret or a ConfigMap, and must contain the ca.crt key.
The following diagram shows how the TLS handshake takes place between the client and NGINX Gateway Fabric:
sequenceDiagram
participant client as Client
participant gw as NGINX Gateway Fabric
participant app as Backend application
client->>gw: Send request and present cert for validation
gw->>client: Present certificate from Secret: cafe-secret
client->>client: Validate Gateway's certificate
gw->>gw: Validate client certificate with CA Secret
gw->>app: Request to backend
app-->>gw: Response
gw-->>client: Response
Before starting, you will need:
- Administrator access to a Kubernetes cluster.
- Helm and kubectl must be installed locally.
- NGINX Gateway Fabric deployed in the Kubernetes cluster.
Frontend TLS requires CA certificates for client certificate validation. This example uses cert-manager to issue these certificates.
Add the Helm repository:
helm repo add jetstack https://charts.jetstack.io
helm repo updateInstall cert-manager:
helm install \
cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
--set config.kind="ControllerConfiguration" \
--set config.enableGatewayAPI=true \
--set crds.enabled=trueCreate a CA issuer to generate our certificates.
WarningThis example uses aselfSignedIssuer, which should not be used in production environments. For production environments, use a real CA issuer.
Next, we create the following resources:
- A self-signed issuer.
- A CA certificate named
default-validation-ca-secretfor our default frontend TLS validation. - A CA certificate named
per-port-validation-ca-secretfor our perPort frontend TLS validation. - A CA certificate named
cafe-secret. The HTTPS listeners on the Gateway reference this certificate and present it to the client during the TLS handshake. This is required for mutual TLS.
WarningFor the Gateway’s certificate, replacecafe.example.comwith the correct hostname for your environment
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: selfsigned-issuer
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: ca-certificate-default
spec:
isCA: true
commonName: LocalCA
secretName: default-validation-ca-secret
issuerRef:
name: selfsigned-issuer
kind: Issuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: default-validation-issuer
spec:
ca:
secretName: default-validation-ca-secret # CA cert for default validation
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: ca-certificate-per-port
spec:
isCA: true
commonName: LocalCA
secretName: per-port-validation-ca-secret
issuerRef:
name: selfsigned-issuer
kind: Issuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: per-port-validation-issuer
spec:
ca:
secretName: per-port-validation-ca-secret # CA cert for perPort validation
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gateway-certificate
spec:
commonName: cafe.example.com
secretName: cafe-secret # Gateway HTTPS Listener cert
dnsNames:
- cafe.example.com
issuerRef:
name: selfsigned-issuer
kind: Issuer
group: cert-manager.io
EOFCreate the coffee and tea applications that will serve as backends. Copy and paste the following block into your terminal:
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: coffee
spec:
replicas: 1
selector:
matchLabels:
app: coffee
template:
metadata:
labels:
app: coffee
spec:
containers:
- name: coffee
image: nginxdemos/nginx-hello:plain-text
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: coffee
spec:
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
selector:
app: coffee
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tea
spec:
replicas: 1
selector:
matchLabels:
app: tea
template:
metadata:
labels:
app: tea
spec:
containers:
- name: tea
image: nginxdemos/nginx-hello:plain-text
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: tea
spec:
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
selector:
app: tea
EOFVerify the pods are running:
kubectl get podsNAME READY STATUS RESTARTS AGE
coffee-6b8b6d6486-7fc78 1/1 Running 0 9s
tea-6fb46d899f-qlmz9 1/1 Running 0 9sCreate a Gateway resource with HTTPS listeners and Frontend TLS client certificate validation. This Gateway configures both sides of mutual TLS:
- HTTPS termination (Gateway validation by client): Each listener references
cafe-secretthroughcertificateRefs. The Gateway presents this certificate to clients during the TLS handshake, allowing clients to verify the Gateway’s identity. - Frontend TLS (client validation by Gateway): The
tls.frontendsection configures client certificate validation using CA certificates. The Gateway uses these CA certificates to verify incoming client certificates.
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: gateway
spec:
gatewayClassName: nginx
listeners:
- name: https
port: 443
protocol: HTTPS
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: cafe-secret
- name: https-2
port: 8443
protocol: HTTPS
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: cafe-secret
tls:
frontend:
default: # Default applies to all other HTTPS listeners
validation:
caCertificateRefs:
- kind: Secret
group: ""
name: default-validation-ca-secret
namespace: default
perPort:
- port: 443 # perPort overrides default for this port
tls:
validation:
caCertificateRefs:
- group: ""
kind: Secret
name: per-port-validation-ca-secret
namespace: default
mode: AllowValidOnly
EOFKey details:
HTTPS termination:
- The
listeners[].tls.mode: Terminatesetting tells the Gateway to terminate TLS and decrypt traffic. - The
certificateRefsfield references the Secret containing the Gateway’s TLS certificate and key. The Gateway presents this certificate to clients during the TLS handshake.
Frontend TLS:
- The
tls.frontend.default.validationsection defines the default client certificate validation. This applies to all HTTPS listeners unless overridden by aperPortconfiguration. This references the Secretdefault-validation-ca-secret. - The
tls.frontend.perPortsection overrides the default validation for a specific listener port. In this example, port443usesAllowValidOnlymode with theper-port-validation-ca-secretCA. - The
caCertificateRefsfield references the CA certificate used to validate client certificates. This can be either aSecretor aConfigMapwith aca.crtkey. - If
modeis omitted,AllowValidOnlyis applied by default.
Confirm the Gateway was created and is programmed:
kubectl describe gateways.gateway.networking.k8s.io gatewayYou should see the Gateway status is Accepted and Programmed:
Status:
Addresses:
Type: IPAddress
Value: 10.96.36.219
Conditions:
Type: Accepted
Status: True
Type: Programmed
Status: TrueYou should also see that both Listeners on the Gateway are also Accepted and Programmed:
Listeners:
Attached Routes: 0
Conditions:
Last Transition Time: 2026-05-05T07:38:57Z
Message: The Listener is programmed
Observed Generation: 1
Reason: Programmed
Status: True
Type: Programmed
Last Transition Time: 2026-05-05T07:38:57Z
Message: The Listener is accepted
Observed Generation: 1
Reason: Accepted
Status: True
Type: Accepted
Last Transition Time: 2026-05-05T07:38:57Z
Message: All references are resolved
Observed Generation: 1
Reason: ResolvedRefs
Status: True
Type: ResolvedRefs
Last Transition Time: 2026-05-05T07:38:57Z
Message: No conflicts
Observed Generation: 1
Reason: NoConflicts
Status: False
Type: Conflicted
Name: httpsSave the public IP address and ports of the Gateway into shell variables. In this example, you need the port values for both frontend validation modes.
GW_IP=XXX.YYY.ZZZ.III
GW_DEFAULT=<port number>
GW_PER_PORT=<port number>Copy the YAML code below into your terminal to create HTTPRoutes to route traffic to the backend applications:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: tea
spec:
parentRefs:
- name: gateway
sectionName: https # Uses port 443
hostnames:
- "cafe.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /tea
backendRefs:
- name: tea
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: coffee
spec:
parentRefs:
- name: gateway
sectionName: https-2 # Uses port 8443
hostnames:
- "cafe.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /coffee
backendRefs:
- name: coffee
port: 80
EOFVerify the HTTPRoute was created:
kubectl describe httproutes | grep -i status -A 10Status:
Parents:
Conditions:
Last Transition Time: 2026-05-06T06:57:53Z
Message: The Route is accepted
Observed Generation: 1
Reason: Accepted
Status: True
Type: Accepted
Last Transition Time: 2026-05-06T06:57:53Z
Message: All references are resolved
Observed Generation: 1
Reason: ResolvedRefs
Status: True
Type: ResolvedRefs
Controller Name: gateway.nginx.org/nginx-gateway-controller
Parent Ref:
Group: gateway.networking.k8s.io
Kind: Gateway
Name: gateway
Namespace: default
Section Name: https-2
Events: <none>
--
Status:
Parents:
Conditions:
Last Transition Time: 2026-05-06T06:57:53Z
Message: The Route is accepted
Observed Generation: 1
Reason: Accepted
Status: True
Type: Accepted
Last Transition Time: 2026-05-06T06:57:53Z
Message: All references are resolved
Observed Generation: 1
Reason: ResolvedRefs
Status: True
Type: ResolvedRefs
Controller Name: gateway.nginx.org/nginx-gateway-controller
Parent Ref:
Group: gateway.networking.k8s.io
Kind: Gateway
Name: gateway
Namespace: default
Section Name: https
Events: <none>To send requests to the Gateway, you must provide a valid certificate and key signed by a valid Certificate Authority (CA).
Copy the following block into your terminal to create two Certificate resources.
This will create two Secret resources with TLS certs and keys signed by the CAs created earlier in this example.
WarningReplacecafe.example.comwith the correct hostname for your environment
kubectl apply -f - <<EOF
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: nginx-cert-default
spec:
secretName: nginx-secret-default
issuerRef:
name: default-validation-issuer
kind: Issuer
commonName: cafe.example.com
dnsNames:
- cafe.example.com
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: nginx-cert-per-port
spec:
secretName: nginx-secret-per-port
issuerRef:
name: per-port-validation-issuer
kind: Issuer
commonName: cafe.example.com
dnsNames:
- cafe.example.com
EOFUse the following curl command to send a request to /coffee with the cert and key created earlier.
This request uses the cert and key signed by the CA used in the default frontend validation.
In this example, the request should pass.
curl --resolve cafe.example.com:$GW_DEFAULT:$GW_IP \
https://cafe.example.com:$GW_DEFAULT/coffee \
--cert <(kubectl get secret nginx-secret-default -o jsonpath='{.data.tls\.crt}' | base64 -d) \
--key <(kubectl get secret nginx-secret-default -o jsonpath='{.data.tls\.key}' | base64 -d) \
-kServer address: 10.244.0.11:8080
Server name: coffee-654ddf664b-pg7mg
Date: 01/May/2026:11:23:51 +0000
URI: /coffee
Request ID: 040e7899523c1a52194176b5808db9e4The request succeeds because a valid client certificate signed by the trusted CA was presented.
Now, let’s also send a request to /tea with the same cert and key used in the /coffee request. Since tea references the listener named https, the CA in per-port-validation-ca-secret validates the cert and key provided in the request. Because the cert and key were signed by the CA in default-validation-ca-secret, the request should fail.
curl --resolve cafe.example.com:$GW_PER_PORT:$GW_IP \
https://cafe.example.com:$GW_PER_PORT/tea \
--cert <(kubectl get secret nginx-secret-default -o jsonpath='{.data.tls\.crt}' | base64 -d) \
--key <(kubectl get secret nginx-secret-default -o jsonpath='{.data.tls\.key}' | base64 -d) \
-kcurl: (92) HTTP/2 stream 1 was not closed cleanly: PROTOCOL_ERROR (err 1)Finally, send a request to /tea with the cert and key signed by the CA in per-port-validation-ca-secret:
curl --resolve cafe.example.com:$GW_PER_PORT:$GW_IP \
https://cafe.example.com:$GW_PER_PORT/tea \
--cert <(kubectl get secret nginx-secret-per-port -o jsonpath='{.data.tls\.crt}' | base64 -d) \
--key <(kubectl get secret nginx-secret-per-port -o jsonpath='{.data.tls\.key}' | base64 -d) \
-kServer address: 10.244.0.12:8080
Server name: tea-75bc9f4b6d-cjz47
Date: 05/May/2026:08:08:51 +0000
URI: /tea
Request ID: c326f89b0541b6109cf2a9306c45d0cdSome frontend load balancers strip out SNI information before the traffic reaches the NGINX gateway. In order for NGINX to still process and forward this traffic properly, you must define your HTTPS Listener without a hostname. This instructs NGINX Gateway Fabric to configure a default HTTPS virtual server to handle non-SNI traffic. The TLS configuration on this Listener will be used to verify and terminate TLS for this traffic, before the Host header is then used to forward to the proper virtual server to handle the request. You can attach your HTTPRoutes to this empty Listener.
By default, NGINX Gateway Fabric verifies that the Listener hostname matches both the SNI and Host header on an incoming client request. This does not require the SNI and Host header to be the same. This is to avoid misdirected requests, and returns a 421 response code. If you run into issues and want to disable this SNI/Host verification, you can update the NginxProxy CRD with the following field in the spec:
spec:
disableSNIHostValidation: true- Gateway API TLS configuration
- HTTPS termination: Configure HTTPS termination and HTTP-to-HTTPS redirects.
- Secure traffic using Let’s Encrypt and cert-manager
- Securing backend traffic using mutual TLS