Client certificates (mTLS)
Webda can authenticate requests with X.509 TLS client certificates, in two modes:
- Native: the
HttpServerterminates TLS, requests the client certificate and verifies it against a CA. - Proxy: a reverse proxy (Traefik, nginx, Envoy) terminates mTLS and forwards the verified certificate in a header.
Webda only accepts that header from its
trustedProxies.
The certificate is exposed on the request, CertificateProvider from @webda/auth turns it into a user session and
ClientCertificateFilter can reserve paths to certificate holders.
Native mode
{
"services": {
"HttpServer": {
"key": "/certs/tls.key",
"cert": "/certs/tls.crt",
"clientCa": "/certs/ca.crt", // or a list of files
"clientAuth": "optional" // "none" | "optional" | "require"
}
}
}
clientAuth | Behaviour |
|---|---|
none | No certificate is requested (default without clientCa) |
optional | A certificate is requested; clients without one, or with one not signed by clientCa, still connect. The latter is exposed with verified: false (default with clientCa) |
require | The TLS handshake fails for clients without a certificate signed by clientCa |
clientCa/clientAuth need TLS (key and cert, or autoTls): the server refuses to start otherwise. The plain
HTTP to HTTPS redirect of the TLS port keeps working. HTTP/1.1, HTTP/2, gRPC and GraphQL subscriptions (WebSocket)
expose the certificate the same way.
Proxy mode
{
"services": {
"HttpServer": {
"trustedProxies": "10.0.0.0/8",
"clientCertHeader": "x-forwarded-tls-client-cert",
"clientCertHeaderFormat": "traefik" // "traefik" | "pem-urlencoded" | "xfcc"
}
}
}
| Proxy | clientCertHeader | clientCertHeaderFormat |
|---|---|---|
Traefik passTLSClientCert with pem: true | x-forwarded-tls-client-cert | traefik |
nginx $ssl_client_escaped_cert | x-ssl-client-cert | pem-urlencoded |
Envoy forward_client_cert_details | x-forwarded-client-cert | xfcc |
- A request carrying
clientCertHeaderfrom an address outsidetrustedProxiesis refused with a 400, like theX-Forwarded-*headers. - The leaf certificate is used (first of the Traefik chain, last XFCC element: the one added by the closest proxy,
configure Envoy with
SANITIZE_SET). - A forwarded certificate is considered
verified: configure the proxy to verify it (TraefikRequireAndVerifyClientCertorVerifyClientCertIfGiven, nginxssl_verify_client on|optional). Never use a mode that forwards unverified certificates, such as TraefikRequestClientCert. - A malformed header is ignored (logged as a warning).
- Requests from a trusted proxy only use the header: when the proxy connects to Webda over TLS with its own client certificate, that certificate is never taken for the client's.
In proxy mode, trustedProxies is the application-level guard only. Make sure that nothing but the proxy can reach
Webda (Kubernetes NetworkPolicy, security groups...), otherwise anyone in the trusted range can forge the header.
Reading the certificate
const cert = useContext<WebContext>().getHttpContext().getClientCertificate();
if (cert?.verified) {
cert.subject.CN; // "alice", a repeated attribute (several OU) is a string[]
cert.subjectDN; // "O=Webda, OU=dev, CN=alice"
cert.issuer; // same for the issuer, cert.issuerDN
cert.san; // ["email:alice@example.com", "URI:spiffe://example.com/alice"]
cert.fingerprint256; // "AB:CD:..."
(cert.serialNumber, cert.validFrom, cert.validTo, cert.raw); // raw is the PEM
cert.source; // "tls" | "proxy"
}
CertificateProvider
CertificateProvider is an authentication provider (providerName certificate) mapping
verified certificates to users through an Ident <uid>:certificate.
{
"services": {
"Authentication": { "type": "Webda/Authentication" },
"CertificateProvider": {
"type": "Webda/CertificateProvider",
"mode": "login", // "login" (default) | "factor"
"identityField": "san.email", // "fingerprint256" (default) | "subject.CN" | "san.email" | "san.uri"
"allowedIssuers": ["webda-client-ca"], // issuer CN or DN, optional
"allowedSubjects": ["^O=Webda, "], // regexps on the subject DN, optional
"autoRegister": false, // default false
"roles": { "OU=admins": ["admin"] }, // optional
"cacheTtl": 60000 // ms, default 60000, 0 disables
}
}
}
Each request presenting a verified, accepted certificate of a known user gets a stateless session (no cookie is
written): provider is certificate, amr is ["mtls"] and roles come from the roles mapping. Permission queries
can rely on it, for example amr CONTAINS 'mtls'.
Set mode: "factor" to require an existing cookie or Bearer session. The certificate must be linked to that same
user; then mtls is added to the request's amr while the original provider and MFA state remain intact. A
certificate alone never logs a user in or registers one in this mode, even if autoRegister is set. Certificate roles
are not added to the existing session. The added mtls method is only present while the certificate is presented:
it is not saved in the cookie or included in newly issued access and refresh tokens. In factor mode,
Auth.Certificate.Link returns { "status": "linked" } without logging in again or issuing tokens.
In login mode, session precedence is a Bearer token, then the certificate, then the session cookie. An unknown or
refused certificate falls back to the cookie session. In factor mode, the Bearer or cookie session is loaded first,
then the matching certificate adds mtls for that request.
identityField:fingerprint256pins one certificate (a renewed certificate must be linked again), the other fields survive a renewal but trust the CA not to issue the same subject or SAN to someone else.- A certificate is accepted when it is valid now, its issuer CN or DN is in
allowedIssuers(when set) and its subject DN matches one ofallowedSubjects(when set). autoRegister: the first request of an unknown certificate goes throughAuthentication.complete(); theregistrationandlinkingpolicies apply (the email of the certificate is not considered verified), and the usualAuthentication.Register/Authentication.Loginevents are emitted.- Without
autoRegister, a logged user links the certificate presented with the request withPOST /auth/certificate/link(operationAuth.Certificate.Link). It fails with 401 without login, 403 without a verified and accepted certificate,IDENT_LINKED_ELSEWHEREwhen it belongs to another user. Unlink it withAuth.Unlink(provider: "certificate"). - Resolved certificates are cached
cacheTtlms per instance: an unlink is effective at once on the instance that handled it, aftercacheTtlon the others. - In
loginmode, certificate sessions skip the MFA step: the certificate is the possession factor. Infactormode, the certificate does not finish an MFA session that is still pending another method.
Browsers present client certificates automatically, cross-site requests included: like a cookie without SameSite, a
certificate session is an ambient credential. For browser clients keep an origin check (csrfOrigins) on the
state-changing routes, or reserve certificate authentication to non-browser clients.
ClientCertificateFilter
A request filter (request-filter capability, like Hawk) allowing requests presenting a verified certificate:
{
"services": {
"CertOnly": {
"type": "Webda/ClientCertificateFilter",
"paths": ["^/internal/"] // regexps on the relative path, all paths when undefined
}
}
}
Request filters are combined with OR: a request is allowed when any filter allows it. With this filter alone, only
certificate holders reach paths, every other request is refused (403).
Revocation
CRL and OCSP are not checked. Prefer short-lived certificates (cert-manager renews them) and unlink a compromised
certificate with Auth.Unlink.
See Traefik and cert-manager for a Kubernetes setup.