mTLS with Traefik and cert-manager
This page sets up client certificate authentication on Kubernetes:
- cert-manager issues the client CA and the client certificates (and the server certificate).
- Traefik terminates mTLS, verifies the client certificate and forwards it to Webda.
- Webda trusts the forwarded certificate only from Traefik (
trustedProxies) and maps it to a user withCertificateProvider.
cert-manager
A self-signed root becomes the CA issuing client certificates:
apiVersion: cert-manager.io/v1
kind: Issuer
metadata: { name: selfsigned }
spec: { selfSigned: {} }
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: { name: client-ca }
spec:
isCA: true
commonName: webda-client-ca
secretName: client-ca
issuerRef: { name: selfsigned, kind: Issuer }
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata: { name: client-ca }
spec: { ca: { secretName: client-ca } }
---
# One client certificate; cert-manager renews it before expiry
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: { name: client-alice }
spec:
commonName: alice
emailAddresses: [alice@example.com]
usages: [client auth, digital signature, key encipherment]
secretName: client-alice
issuerRef: { name: client-ca, kind: Issuer }
The client-alice secret holds tls.crt and tls.key for the client:
kubectl get secret client-alice -o jsonpath='{.data.tls\.crt}' | base64 -d > alice.crt
kubectl get secret client-alice -o jsonpath='{.data.tls\.key}' | base64 -d > alice.key
curl --cert alice.crt --key alice.key https://api.example.com/auth/me
Proxy mode: Traefik terminates mTLS
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata: { name: mtls }
spec:
clientAuth:
secretNames: [client-ca]
clientAuthType: RequireAndVerifyClientCert # or VerifyClientCertIfGiven
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata: { name: pass-client-cert }
spec:
passTLSClientCert:
pem: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata: { name: webda }
spec:
entryPoints: [websecure]
routes:
- match: Host(`api.example.com`)
kind: Rule
middlewares: [{ name: pass-client-cert }]
services: [{ name: webda, port: 18080 }]
tls:
secretName: api-example-com # cert-manager Certificate (ACME)
options: { name: mtls }
Use RequireAndVerifyClientCert or VerifyClientCertIfGiven only: Webda considers the forwarded certificate verified,
RequestClientCert and RequireAnyClientCert would forward unverified certificates.
Webda configuration:
{
"services": {
"HttpServer": { "trustedProxies": "10.0.0.0/8", "clientCertHeader": "x-forwarded-tls-client-cert" },
"Authentication": { "type": "Webda/Authentication" },
"CertificateProvider": { "type": "Webda/CertificateProvider", "identityField": "san.email", "autoRegister": false }
}
}
trustedProxies is the application-level guard: a NetworkPolicy must ensure only Traefik reaches the Webda pods,
otherwise any pod in 10.0.0.0/8 can forge X-Forwarded-Tls-Client-Cert.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: webda-from-traefik }
spec:
podSelector: { matchLabels: { app: webda } }
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: traefik } }
podSelector: { matchLabels: { app.kubernetes.io/name: traefik } }
ports: [{ port: 18080 }]
Native mode: Webda terminates mTLS
Mount the server certificate and the client CA, and let Traefik pass the TLS connection through. Traefik then routes on SNI only, it cannot route on paths nor add headers.
{
"services": {
"HttpServer": {
"key": "/certs/server/tls.key",
"cert": "/certs/server/tls.crt",
"clientCa": "/certs/client-ca/ca.crt",
"clientAuth": "optional"
}
}
}
# Deployment excerpt
volumes:
- name: server-cert
secret: { secretName: api-example-com }
- name: client-ca
secret: { secretName: client-ca, items: [{ key: ca.crt, path: ca.crt }] }
containers:
- name: webda
volumeMounts:
- { name: server-cert, mountPath: /certs/server, readOnly: true }
- { name: client-ca, mountPath: /certs/client-ca, readOnly: true }
---
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata: { name: webda }
spec:
entryPoints: [websecure]
routes:
- match: HostSNI(`api.example.com`)
services: [{ name: webda, port: 18080 }]
tls:
passthrough: true
The certificates are read when the server starts: restart the pods when cert-manager renews the server certificate or the client CA.
Revocation
CRL/OCSP are not checked: issue short-lived client certificates (duration/renewBefore on the cert-manager
Certificate) and unlink a compromised certificate with Auth.Unlink.