Respuesta breve. En el TLS mutuo (mTLS), el servidor envía un CertificateRequest y el cliente responde con su cadena de certificados y una firma sobre el handshake, con la que demuestra que posee la clave privada. El servidor valida esa cadena con su propia ancla de confianza, una autoridad de certificación (CA) privada, y después asocia el certificado a una identidad, idealmente un único URI SAN, como un SPIFFE ID. Hay cuatro cosas que deben estar bien: el cliente debe enviar su certificado intermedio (o el servidor debe tenerlo), el servidor confía solo en la CA que emite los certificados de cliente, la autorización se hace después de la autenticación (un certificado válido no es un permiso) y los certificados son de corta duración y se renuevan automáticamente. No uses certificados TLS públicos para los clientes: las jerarquías públicas están retirando clientAuth.
El laboratorio se ejecutó en imágenes con versiones fijadas: debian:forky-20261005-slim (OpenSSL 3.6.5, nginx 1.30.4, curl 8.22.0), envoyproxy/envoy:v1.39.4, golang:1.27.3-trixie y node:24.22.0-bookworm-slim. Todas las claves y todos los certificados se generaron para este laboratorio.
Dónde encaja mTLS
mTLS autentica una máquina, una carga de trabajo o un dispositivo que tiene una clave, en el nivel de la conexión, antes de que se analice ninguna solicitud HTTP. Por eso es una buena opción para:
- Tráfico entre servicios dentro de una plataforma, donde una malla de servicios o un sidecar (Istio, Linkerd, Consul, Envoy a secas) emite y rota los certificados de cada carga de trabajo.
- API de socios y B2B, donde cada socio recibe un certificado de tu CA; el perfil de seguridad FAPI 2.0 acepta mTLS tanto para la autenticación del cliente (junto a
private_key_jwt) como para vincular tokens de acceso (junto a DPoP). - Dispositivos y agentes que pueden guardar una clave en hardware (un TPM, un elemento seguro) y presentarla en cada conexión.
Con los navegadores encaja mal: los usuarios ven selectores de certificados, y una CDN o un balanceador de carga que termine TLS delante de ti tiene que admitir, a su vez, certificados de cliente. Cuando la conexión no es de extremo a extremo, las pruebas a nivel de solicitud, como DPoP o las firmas de mensajes HTTP, viajan mejor (consulta el final de esta página).
Qué añade el handshake
En TLS 1.3 (RFC 9846, que sustituyó a RFC 8446 en julio de 2026), el CertificateRequest del servidor llega justo después de EncryptedExtensions. Debe llevar signature_algorithms y puede enumerar en certificate_authorities los nombres de las CA que acepta (§4.4.2). El cliente responde con Certificate, CertificateVerify (su firma sobre la transcripción) y Finished. Si el cliente no envía nada, el servidor «PUEDE, a su discreción, continuar el handshake sin autenticación del cliente o abortar el handshake con una alerta certificate_required» (§4.5.1.3).
openssl s_client muestra lo que pide un servidor. Con el servidor nginx de más abajo:
Acceptable client certificate CA names
O=Example, CN=Example Internal Root
Requested Signature Algorithms: id-ml-dsa-65:id-ml-dsa-87:id-ml-dsa-44:ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:...
En la práctica importan dos consecuencias. Primero, los clientes usan esa lista de CA para elegir un certificado, y algunos se niegan a enviar uno cuya cadena no llegue a ninguno de los nombres de la lista (Go lo hace, como se ve más abajo). Segundo, en TLS 1.3 el cliente envía su certificado después de recibir el Finished del servidor, así que, desde el punto de vista del cliente, el handshake ya está completo antes de que el servidor haya evaluado el certificado. Por eso los rechazos aparecen en la primera lectura del cliente, como una alerta, y por eso curl los muestra como (56) ... SSL_read y no como un error del handshake.
Una CA privada para los certificados de cliente
Los certificados de cliente proceden de una CA que gestionas tú (o que alquilas como CA privada gestionada), no de una pública. Las CA públicas no pueden validar «este es nuestro worker de facturación», y las jerarquías TLS públicas están eliminando la autenticación de cliente: el Chrome Root Program exige certificados de suscriptor solo con serverAuth a partir del 15 de marzo de 2027, y Let's Encrypt dejó de emitir clientAuth el 8 de julio de 2026 (consulta Campos de un certificado X.509).
La jerarquía del laboratorio: una raíz, una CA emisora con pathlen:0, un certificado de servidor para api.internal.test y certificados de cliente cuya identidad es un URI SAN en formato SPIFFE:
[issuing]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
extendedKeyUsage = serverAuth, clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
[worker7]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectAltName = URI:spiffe://example.test/ns/prod/sa/worker-7
authorityKeyIdentifier = keyid
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out worker-7.key
openssl req -new -key worker-7.key -subj "/O=Example/CN=worker-7" -out worker-7.csr
openssl x509 -req -in worker-7.csr -CA issuing.pem -CAkey issuing.key -extfile ext.cnf -extensions worker7 -days 1 -out worker-7.pem
cat worker-7.pem issuing.pem > worker-7-chain.pem
Un URI SAN da una identidad tipada e inequívoca; el X.509-SVID de SPIFFE exige exactamente uno y establece que los validadores «DEBEN rechazar» los certificados que tengan más. Un SVID completo, tal como lo emite SPIRE, también incluye serverAuth en el EKU. Además de worker-7, el laboratorio emitió billing (la misma CA, otro SPIFFE ID), intruder (el mismo nombre y el mismo ID, firmado por una raíz ajena), expired (válido el 1 de septiembre de 2026) y worker-9, emitido por una segunda CA emisora situada dos niveles por debajo de la raíz.
nginx
server {
listen 8443 ssl;
server_name api.internal.test;
error_log /var/log/nginx/mtls.log info; # client certificate errors are logged at "info"
ssl_certificate /w/pki/server-chain.pem;
ssl_certificate_key /w/pki/server.key;
ssl_client_certificate /w/pki/root.pem; # trust anchor for client certificates
ssl_verify_client on;
ssl_verify_depth 2;
location / {
default_type text/plain;
return 200 "verify=$ssl_client_verify subject=$ssl_client_s_dn issuer=$ssl_client_i_dn\n";
}
}
Cada caso con curl --cert ... --key ... y, después, el registro:
--- no client certificate
<head><title>400 No required SSL certificate was sent</title></head>
--- worker-7, leaf only
<head><title>400 The SSL certificate error</title></head>
--- worker-7 with its intermediate
verify=SUCCESS subject=CN=worker-7,O=Example issuer=CN=Example Issuing CA 1,O=Example
--- certificate from another CA
<head><title>400 The SSL certificate error</title></head>
--- expired certificate
<head><title>400 The SSL certificate error</title></head>
[info] client sent no required SSL certificate while reading client request headers
[info] client SSL certificate verify error: (21:unable to verify the first certificate) while reading client request headers
[info] client SSL certificate verify error: (21:unable to verify the first certificate) while reading client request headers
[info] client SSL certificate verify error: (10:certificate has expired) while reading client request headers
Puntos que conviene observar:
- nginx termina el handshake y responde con HTTP 400 (No required SSL certificate was sent); internamente son los códigos no estándar 496 (sin certificado) y 495 (falló la verificación), que
error_pagepuede convertir en tu propia respuesta. - El motivo solo aparece en el registro de errores, y solo en el nivel
info. Con el nivel predeterminado,error, no ves nada. - Enviar solo la hoja falla. El servidor confía en la raíz; sin el certificado intermedio no hay ruta, de ahí el error 21. O bien el cliente envía
leaf + intermediate(--certde curl acepta un archivo de cadena), o bien el servidor añade el certificado intermedio a su archivo de CA. ssl_verify_depthcuenta los certificados intermedios. El valor predeterminado es 1. Repetimos la prueba con profundidad 1:worker-7(un intermedio) siguió pasando, yworker-9(dos intermedios) falló con(20:unable to get local issuer certificate)y pasó con profundidad 2.ssl_client_certificateenvía su lista de CA a los clientes;ssl_trusted_certificateverifica con el mismo tipo de archivo sin anunciarlo (documentación de nginx). Usa el primero cuando los clientes necesiten esa pista para elegir un certificado.
Para transmitir la identidad, proxy_set_header X-Client-Cert $ssl_client_escaped_cert; reenvía el PEM con codificación URL, y $ssl_client_verify vale SUCCESS, FAILED:reason o NONE. Con ssl_verify_client optional, comprueba tú mismo $ssl_client_verify antes de reenviar la solicitud.
Envoy
Envoy, además, puede comparar la identidad durante el handshake y entregársela a la aplicación en x-forwarded-client-cert (XFCC):
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
require_client_certificate: true
common_tls_context:
tls_params: { tls_minimum_protocol_version: TLSv1_3 }
tls_certificates:
- certificate_chain: { filename: /w/pki/server-chain.pem }
private_key: { filename: /w/pki/server.key }
validation_context:
trusted_ca: { filename: /w/pki/root.pem }
match_typed_subject_alt_names:
- san_type: URI
matcher: { exact: "spiffe://example.test/ns/prod/sa/worker-7" }
# in the http_connection_manager of the same listener:
forward_client_cert_details: SANITIZE_SET
set_current_client_cert_details: { uri: true }
En el laboratorio, el backend es un segundo listener de Envoy que devuelve el encabezado XFCC que recibe. Resultados con curl 7.81.0 desde la imagen de Envoy y, después, las estadísticas del listener de Envoy:
--- worker-7
HTTP/1.1 200 OK
x-backend-saw-xfcc: Hash=048c219f601a877b19c77fc8834750cdc6b1394b9080a50fa8eaeaf6f3793bd0;URI=spiffe://example.test/ns/prod/sa/worker-7
--- worker-7 sending its own x-forwarded-client-cert
HTTP/1.1 200 OK
x-backend-saw-xfcc: Hash=048c219f601a877b19c77fc8834750cdc6b1394b9080a50fa8eaeaf6f3793bd0;URI=spiffe://example.test/ns/prod/sa/worker-7
--- billing (valid certificate, SAN not allowed)
curl: (56) OpenSSL SSL_read: error:0A000416:SSL routines::sslv3 alert certificate unknown, errno 0
--- no client certificate
curl: (56) OpenSSL SSL_read: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
--- certificate from another CA
curl: (56) OpenSSL SSL_read: error:0A000418:SSL routines::tlsv1 alert unknown ca, errno 0
listener.127.0.0.1_10443.ssl.fail_verify_error: 1
listener.127.0.0.1_10443.ssl.fail_verify_no_cert: 1
listener.127.0.0.1_10443.ssl.fail_verify_san: 1
listener.127.0.0.1_10443.ssl.handshake: 2
La segunda solicitud es la importante: el cliente envió su propio encabezado XFCC, en el que afirmaba ser admin, y SANITIZE_SET lo sustituyó por lo que el proxy había verificado. Todo proxy que reenvíe la identidad del cliente en un encabezado debe eliminar la copia entrante; una aplicación solo debe aceptar ese encabezado si viene del proxy. Cada fallo corresponde a una alerta (certificate required, unknown ca, certificate unknown cuando el SAN no coincide) y a un contador propio, que es lo primero que hay que mirar en producción.
Go
tls.RequireAndVerifyClientCert con un pool en ClientCAs se encarga de la verificación; la autorización por SPIFFE ID es código corriente del manejador:
var allowed = map[string]string{"spiffe://example.test/ns/prod/sa/worker-7": "/jobs"}
func handler(w http.ResponseWriter, r *http.Request) {
// With RequireAndVerifyClientCert, VerifiedChains[0][0] is the verified client leaf.
leaf := r.TLS.VerifiedChains[0][0]
if len(leaf.URIs) != 1 {
http.Error(w, "client certificate must carry exactly one URI SAN", http.StatusForbidden)
return
}
id := leaf.URIs[0].String()
if allowed[id] != r.URL.Path {
http.Error(w, id+" may not call "+r.URL.Path, http.StatusForbidden)
return
}
fmt.Fprintf(w, "hello %s, cert valid until %s\n", id, leaf.NotAfter.Format(time.RFC3339))
}
func server(ln net.Listener) {
cert, err := tls.LoadX509KeyPair(pki+"server-chain.pem", pki+"server.key")
if err != nil {
log.Fatal(err)
}
srv := &http.Server{
Handler: http.HandlerFunc(handler),
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: pool("root.pem"),
MinVersion: tls.VersionTLS13,
},
ErrorLog: log.New(os.Stdout, "server log: ", 0),
}
srv.ServeTLS(ln, "", "")
}
El cliente del mismo programa establece tls.Config.Certificates y llama a /jobs con cada certificado; para un caso adicional usa GetClientCertificate y envía un certificado sin condiciones:
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: client didn't provide a certificate
no client certificate client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: certificate required
worker-7 200 hello spiffe://example.test/ns/prod/sa/worker-7, cert valid until 2026-10-21T15:17:06Z
billing 403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: client didn't provide a certificate
certificate from another CA client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: certificate required
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: failed to verify certificate: x509: certificate signed by unknown authority
same, sent anyway client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: unknown certificate authority
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: failed to verify certificate: x509: certificate has expired or is not yet valid: current time 2026-10-20T15:18:44Z is after 2026-09-02T00:00:00Z
expired certificate client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: expired certificate
El cliente de Go no llegó a enviar el certificado intruder: ninguno de los certificados de su cadena lo había emitido una CA de la lista certificate_authorities del servidor, así que respondió con un mensaje Certificate vacío y el servidor vio «client didn't provide a certificate». Si un cliente de Go bien configurado devuelve certificate required, lo primero que tienes que hacer es comparar los emisores de su cadena con la lista de CA del servidor. Cuando se forzó el envío, el certificado falló con unknown certificate authority. billing muestra la separación entre autenticación (el certificado es válido) y autorización (403).
Node.js
function authorize(req, res) {
// With rejectUnauthorized: false the handshake succeeds for any certificate (or none),
// so this check is the only thing standing between the request and the handler.
if (!req.socket.authorized) {
res.writeHead(401).end(`client certificate rejected: ${req.socket.authorizationError}\n`);
return;
}
const cert = req.socket.getPeerCertificate();
// subjectaltname is a string such as "URI:spiffe://..." — parse it, do not substring-match it.
const uris = (cert.subjectaltname ?? "").split(", ").filter((n) => n.startsWith("URI:")).map((n) => n.slice(4));
const id = uris.length === 1 ? uris[0] : null;
if (!id || ALLOWED.get(id) !== req.url) {
res.writeHead(403).end(`${id ?? "no single URI SAN"} may not call ${req.url}\n`);
return;
}
res.end(`hello ${id}, serial ${cert.serialNumber}\n`);
}
const serverOptions = (rejectUnauthorized) => ({
key: pki("server.key"),
cert: pki("server-chain.pem"),
ca: pki("root.pem"), // trust anchor for client certificates
requestCert: true, // send CertificateRequest
rejectUnauthorized, // true: drop connections whose client certificate does not verify
minVersion: "TLSv1.3",
});
El laboratorio ejecutó las mismas cinco llamadas contra un servidor con rejectUnauthorized: true y contra otro con false:
node v24.22.0, OpenSSL 3.5.8
### rejectUnauthorized: true
server tlsClientError: ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE
no client certificate client error: ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED: ...:tlsv13 alert certificate required:...:SSL alert number 116
worker-7 200 hello spiffe://example.test/ns/prod/sa/worker-7, serial 7D2333CCB4C609E16E5592C959C71250AD4703BA
billing 403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
server tlsClientError: ECONNRESET
certificate from another CA client error: ECONNRESET: socket hang up
server tlsClientError: ECONNRESET
expired certificate client error: ECONNRESET: socket hang up
### rejectUnauthorized: false
no client certificate 401 client certificate rejected: UNABLE_TO_GET_ISSUER_CERT
worker-7 200 hello spiffe://example.test/ns/prod/sa/worker-7, serial 7D2333CCB4C609E16E5592C959C71250AD4703BA
billing 403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
certificate from another CA 401 client certificate rejected: UNABLE_TO_VERIFY_LEAF_SIGNATURE
expired certificate 401 client certificate rejected: CERT_HAS_EXPIRED
Con rejectUnauthorized: true, Node rechaza un certificado incorrecto cerrando el socket: sin alerta y sin motivo en ninguno de los dos lados, solo ECONNRESET. La ausencia de certificado la detecta OpenSSL antes, y en ese caso sí hay una alerta certificate required en toda regla (peer did not return a certificate en el servidor). Con false, se acepta toda conexión y socket.authorizationError contiene el motivo; ese modo solo es seguro si todas las rutas de solicitud comprueban primero socket.authorized. Mantén true en producción y usa false con la comprobación en un puerto de diagnóstico cuando necesites el motivo.
Emisión y rotación de certificados de cliente
- Vida corta y renovación automatizada. Los certificados de cliente de los servicios deberían durar de horas a días y renovarse mediante un agente, no por una persona: SPIRE para los SPIFFE ID, cert-manager en Kubernetes, step-ca, el motor PKI de HashiCorp Vault o una CA privada en la nube. Un certificado de un día rara vez necesita revocación; si alguno la necesita, nginx puede comprobar una CRL con
ssl_crl. - Solapamiento en cada paso. Renueva hacia los dos tercios de la vida útil y deja que el cliente cargue el certificado nuevo sin reiniciarse (Envoy y SPIRE usan para ello la API SDS; Go puede usar
GetClientCertificatepara leer el archivo actual). - Rotación de la CA. Primero añade la nueva raíz al archivo de confianza de todos los servidores, después empieza a emitir con ella y, por último, retira la raíz antigua cuando haya caducado el último certificado emitido con ella. Los servidores confían en un conjunto de raíces, así que las dos pueden ser válidas a la vez.
- Una CA para cada propósito. Una CA que emite certificados de cliente para una API no debería emitir también para otro sistema, porque entonces los servidores de ese sistema aceptarían esos certificados. Los comparadores de SAN de Envoy o tu propio mapa de autorización son la segunda línea de defensa.
Errores frecuentes
- Confiar en el almacén del sistema para los clientes.
ssl_client_certificate /etc/ssl/certs/ca-certificates.crtacepta cualquier certificado de cualquier CA pública, y un certificado público no dice nada de tus clientes. - Autenticar sin autorizar. Entra cualquier certificado válido de tu CA, salvo que una regla diga qué identidad puede llamar a qué, como mostró
billing. - Comparar nombres de forma laxa. Compara exactamente un URI SAN ya analizado; no busques
CN=workeren el texto del sujeto ni compares un prefijo de un valor elegido por un atacante. - Confiar en un encabezado de identidad del cliente venga de quien venga. Solo puede establecerlo el proxy que terminó mTLS, y ese proxy debe eliminar la copia entrante (
SANITIZE_SETen Envoy; en nginx,proxy_set_headerla sobrescribe). - Un certificado de cliente sin
clientAuth. Si el certificado tiene un EKU sinclientAuth, los servidores basados en OpenSSL lo rechazan: al presentar como certificado de cliente el certificado de servidor del laboratorio, que solo tieneserverAuth, nginx registró(26:unsuitable certificate purpose), el error 26 de OpenSSL. - Balanceadores de carga que terminan TLS delante del servidor. La comprobación del certificado ocurre en el primer endpoint TLS. Si ese endpoint es un balanceador de carga, tiene que hacer mTLS y reenviar la identidad, o dejar pasar el TCP sin tocarlo.
En OAuth, RFC 8705 se apoya dos veces en mTLS: los clientes se autentican ante el endpoint de token con un certificado (tls_client_auth), y los tokens de acceso pueden vincularse a ese certificado (cnf con x5t#S256, la huella digital SHA-256), de modo que un token robado no sirve de nada sin la clave. Donde mTLS no llega de extremo a extremo, DPoP ofrece una vinculación similar en la capa HTTP, y las firmas de mensajes HTTP firman solicitudes individuales.
Fuentes
- RFC 9846: TLS 1.3: §4.4.2 (CertificateRequest), §4.5.1.3 (recepción de un mensaje Certificate), §6 (alertas)
- nginx ngx_http_ssl_module:
ssl_verify_client,ssl_verify_depth,ssl_client_certificate,ssl_trusted_certificate, errores 495 y 496 - Envoy: socket de transporte TLS y x-forwarded-client-cert
- crypto/tls de Go; tls.createServer de Node.js
- X.509-SVID de SPIFFE
- RFC 8705: autenticación de cliente con TLS mutuo y tokens de acceso vinculados a certificados en OAuth 2.0
- Política del Chrome Root Program, versión 1.8; Let's Encrypt, Fin del soporte de certificados de autenticación de cliente TLS en 2026