Pular para o conteúdo
ecdsa.com
← Todos os artigos

TLS mútuo para APIs: certificados de cliente na prática

Uma CA privada, cinco certificados de cliente e quatro servidores. Cada configuração abaixo foi executada em 20 de outubro de 2026, e cada mensagem de erro é exatamente a que o nginx 1.30, o Envoy 1.39, o Go 1.27 e o Node.js 24 exibiram.

Resposta curta. No TLS mútuo (mTLS), o servidor envia um CertificateRequest, e o cliente responde com sua cadeia de certificados e uma assinatura sobre o handshake, o que prova que ele possui a chave privada. O servidor valida essa cadeia a partir da sua própria âncora de confiança, uma CA privada, e depois associa o certificado a uma identidade, de preferência um único URI SAN, como um SPIFFE ID. Quatro pontos precisam estar corretos: o cliente precisa enviar seu intermediário (ou o servidor precisa tê-lo), o servidor confia apenas na CA que emite os certificados de cliente, a autorização acontece depois da autenticação (um certificado válido não é uma permissão) e os certificados têm vida curta e são renovados automaticamente. Não use certificados TLS públicos para clientes: as hierarquias públicas estão abandonando o clientAuth.

O laboratório rodou em imagens com versão fixada: 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 e node:24.22.0-bookworm-slim. Todas as chaves e todos os certificados foram gerados para ele.

Onde o mTLS se encaixa

O mTLS autentica uma máquina, uma carga de trabalho ou um dispositivo que guarda uma chave, no nível da conexão, antes que qualquer requisição HTTP seja analisada. Por isso, ele é uma boa opção para:

  • Tráfego entre serviços dentro de uma plataforma, em que um service mesh ou um sidecar (Istio, Linkerd, Consul, Envoy puro) emite certificados para cada carga de trabalho e faz a rotação deles.
  • APIs de parceiros e B2B, em que você emite para cada parceiro um certificado da sua CA; o perfil de segurança FAPI 2.0 aceita o mTLS tanto para a autenticação do cliente (ao lado de private_key_jwt) quanto para vincular tokens de acesso (ao lado do DPoP).
  • Dispositivos e agentes que conseguem manter uma chave em hardware (um TPM, um elemento seguro) e apresentá-la em cada conexão.

Com navegadores, ele funciona mal: os usuários veem seletores de certificado, e uma CDN ou um balanceador de carga que termine o TLS na frente do seu servidor precisa, ele próprio, oferecer suporte a certificados de cliente. Quando a conexão não é de ponta a ponta, provas no nível da requisição, como o DPoP ou as assinaturas de mensagens HTTP, atravessam melhor os intermediários (veja o fim desta página).

O que o handshake acrescenta

No TLS 1.3 (RFC 9846, que substituiu a RFC 8446 em julho de 2026), o CertificateRequest do servidor vem logo depois de EncryptedExtensions. Ele precisa conter signature_algorithms e pode listar, em certificate_authorities, os nomes das CAs que aceita (§4.4.2). O cliente responde com Certificate, CertificateVerify (sua assinatura sobre a transcrição do handshake) e Finished. Se o cliente não enviar nada, o servidor “PODE (MAY), a seu critério, continuar o handshake sem autenticação do cliente ou abortar o handshake com um alerta certificate_required” (§4.5.1.3).

O openssl s_client mostra o que um servidor pede. Com o servidor nginx descrito mais abaixo:

openssl s_client -connect 127.0.0.1:8443 -servername api.internal.test -CAfile root.pem (trecho)
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:...

Duas consequências importam na prática. Primeiro, os clientes usam essa lista de CAs para escolher um certificado, e alguns se recusam a enviar um certificado que não forme cadeia até um dos nomes listados (o Go faz isso, como mostrado abaixo). Segundo, no TLS 1.3 o cliente envia seu certificado depois de receber o Finished do servidor; assim, do ponto de vista do cliente, o handshake está concluído antes de o servidor avaliar o certificado. As rejeições, portanto, aparecem na primeira leitura do cliente, como um alerta, e é por isso que o curl as informa como (56) ... SSL_read, e não como um erro de handshake.

Uma CA privada para certificados de cliente

Os certificados de cliente vêm de uma CA que você opera (ou contrata como CA privada gerenciada), e não de uma CA pública. CAs públicas não conseguem validar “este é o nosso worker de faturamento”, e as hierarquias TLS públicas estão removendo a autenticação de cliente: o Chrome Root Program exige, a partir de 15 de março de 2027, certificados de assinante só com serverAuth, e a Let's Encrypt deixou de emitir clientAuth em 8 de julho de 2026 (veja campos do certificado X.509).

A hierarquia do laboratório: uma raiz, uma CA emissora com pathlen:0, um certificado de servidor para api.internal.test e certificados de cliente cuja identidade é um URI SAN no formato SPIFFE:

ext.cnf (trecho): a CA emissora e um perfil de certificado de cliente
[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
pki.sh (trecho): emitir um certificado de cliente de um dia e o arquivo da sua cadeia
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

Um URI SAN dá uma identidade tipada e sem ambiguidade; o X.509-SVID do SPIFFE exige exatamente um e determina que os validadores “DEVEM rejeitar” (MUST reject) certificados com mais de um. Um SVID completo, como o SPIRE o emite, também inclui serverAuth no EKU. Além de worker-7, o laboratório emitiu billing (mesma CA, outro SPIFFE ID), intruder (mesmo nome e mesmo ID, assinado por uma raiz sem relação com as outras), expired (válido em 1º de setembro de 2026) e worker-9, sob uma segunda CA emissora que fica dois níveis abaixo da raiz.

nginx

/etc/nginx/conf.d/mtls.conf (nginx 1.30.4)
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 com curl --cert ... --key ... e, em seguida, o log:

resultados do curl e, em seguida, o mtls.log (timestamps e endereços dos clientes removidos)
--- 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

Pontos a observar:

  • O nginx conclui o handshake e responde com HTTP 400 (No required SSL certificate was sent); internamente, esses são os códigos não padronizados 496 (sem certificado) e 495 (falha na verificação), que o error_page pode transformar em uma resposta própria.
  • O motivo só aparece no log de erros, e só no nível info. Com o nível padrão, error, você não vê nada.
  • Enviar só o certificado folha falha. O servidor confia na raiz; sem o intermediário não há caminho, daí o erro 21. Ou o cliente envia leaf + intermediate (o --cert do curl aceita um arquivo de cadeia), ou o servidor adiciona o intermediário ao seu arquivo de CAs.
  • ssl_verify_depth conta os intermediários. O padrão é 1. Repetimos o teste com profundidade 1: worker-7 (um intermediário) continuou passando, e worker-9 (dois intermediários) falhou com (20:unable to get local issuer certificate) e passou com profundidade 2.
  • ssl_client_certificate envia sua lista de CAs aos clientes; ssl_trusted_certificate verifica com o mesmo tipo de arquivo, sem divulgar a lista (documentação do nginx). Use o primeiro quando os clientes precisarem dessa dica para escolher um certificado.

Para repassar a identidade, proxy_set_header X-Client-Cert $ssl_client_escaped_cert; encaminha o PEM com codificação de URL, e $ssl_client_verify vale SUCCESS, FAILED:reason ou NONE. Com ssl_verify_client optional, confira você mesmo o $ssl_client_verify antes de repassar a requisição pelo proxy.

Envoy

O Envoy também pode conferir a identidade durante o handshake e entregá-la à aplicação em x-forwarded-client-cert (XFCC):

envoy.yaml (trecho): listener mTLS com um matcher de SAN e 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 }

No laboratório, o backend é um segundo listener do Envoy que devolve o cabeçalho XFCC recebido. Resultados com o curl 7.81.0 da imagem do Envoy e, em seguida, as estatísticas do listener do Envoy:

resultados do curl -i e /stats (Envoy 1.39.4)
--- 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

A segunda requisição é a que importa: o cliente enviou o próprio cabeçalho XFCC afirmando ser admin, e o SANITIZE_SET o substituiu pelo que o proxy verificou. Todo proxy que encaminha a identidade do cliente em um cabeçalho precisa remover a cópia recebida; uma aplicação só deve aceitar esse cabeçalho quando ele vier do proxy. Cada falha corresponde a um alerta (certificate required, unknown ca, certificate unknown quando o SAN não corresponde) e a um contador separado, que é onde olhar primeiro em produção.

Go

tls.RequireAndVerifyClientCert, com um pool ClientCAs, faz a verificação; a autorização por SPIFFE ID é código comum dentro do handler:

main.go (trecho): lado do servidor (Go 1.27.3)
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, "", "")
}

O cliente do mesmo programa define tls.Config.Certificates e chama /jobs com cada certificado; em um caso extra, ele usa GetClientCertificate para enviar um certificado incondicionalmente:

go run . (portas substituídas por PORT)
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

O cliente Go nem chegou a enviar o certificado intruder: nenhum dos certificados da sua cadeia foi emitido por uma CA da lista certificate_authorities do servidor, então ele respondeu com uma mensagem Certificate vazia, e o servidor registrou “client didn't provide a certificate”. Quando um cliente Go configurado corretamente informa certificate required, compare os emissores da cadeia dele com a lista de CAs do servidor antes de qualquer outra coisa. Enviado à força, o certificado falhou com unknown certificate authority. O billing mostra a separação entre autenticação (o certificado é válido) e autorização (403).

Node.js

mtls.mjs (trecho): opções do servidor e autorização (Node.js 24.22.0)
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",
});

O laboratório executou as mesmas cinco chamadas em um servidor com rejectUnauthorized: true e em outro com false:

node mtls.mjs (locais dos erros do OpenSSL encurtados)
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

Com rejectUnauthorized: true, o Node rejeita um certificado ruim fechando o socket: nenhum alerta, nenhum motivo em nenhum dos lados, apenas ECONNRESET. A ausência de certificado é detectada antes, pelo OpenSSL, e recebe um alerta certificate required adequado (peer did not return a certificate no servidor). Com false, toda conexão é aceita e socket.authorizationError traz o motivo; esse modo só é seguro se todo caminho de código que trata uma requisição verificar socket.authorized primeiro. Mantenha true em produção e use false, com essa verificação, em uma porta de diagnóstico quando precisar do motivo.

Emissão e rotação de certificados de cliente

  • Vida curta, renovação automatizada. Convém que certificados de cliente para serviços durem de horas a dias e sejam renovados por um agente, e não por uma pessoa: SPIRE para SPIFFE IDs, cert-manager no Kubernetes, step-ca, o mecanismo de PKI do HashiCorp Vault ou uma CA privada na nuvem. Um certificado de um dia raramente precisa de revogação; se precisar, o nginx pode consultar uma CRL com ssl_crl.
  • Sobreposição em cada etapa. Renove em cerca de dois terços da vida útil e deixe o cliente carregar o novo certificado sem reiniciar (o Envoy e o SPIRE usam a API SDS para isso; o Go pode usar GetClientCertificate para ler o arquivo atual).
  • Rotação da CA. Primeiro, adicione a nova raiz ao arquivo de confiança de cada servidor; depois, comece a emitir a partir dela; por fim, remova a raiz antiga quando o último certificado emitido por ela tiver expirado. Os servidores confiam em um pacote de certificados, então as duas raízes podem ser válidas ao mesmo tempo.
  • Uma CA por finalidade. Não convém que uma CA que emite certificados de cliente para uma API emita também para outro sistema, cujos servidores passariam então a aceitá-los. Os matchers de SAN do Envoy ou o mapa de autorização no seu próprio código são a segunda linha de defesa.

Erros comuns

  • Confiar no trust store do sistema para validar clientes. ssl_client_certificate /etc/ssl/certs/ca-certificates.crt aceita qualquer certificado de qualquer CA pública, e um certificado público não diz nada sobre os seus clientes.
  • Autenticar sem autorizar. Todo certificado válido da sua CA entra, a menos que uma regra diga qual identidade pode chamar o quê, como o billing mostrou.
  • Comparar nomes de forma frouxa. Compare exatamente o URI SAN já analisado; não procure CN=worker na string do titular nem compare um prefixo de um valor escolhido por um atacante.
  • Confiar em um cabeçalho de identidade do cliente vindo de qualquer origem. Só o proxy que fez a terminação do mTLS pode defini-lo, e ele precisa remover a cópia recebida (no Envoy, SANITIZE_SET; no nginx, o proxy_set_header sobrescreve o valor).
  • Certificado de cliente sem clientAuth. Se o certificado tem um EKU sem clientAuth, servidores baseados em OpenSSL o rejeitam: apresentar como certificado de cliente o certificado de servidor do laboratório, que tem só serverAuth, fez o nginx registrar (26:unsuitable certificate purpose), o erro 26 do OpenSSL.
  • Balanceadores de carga que terminam o TLS na frente do servidor. A verificação do certificado acontece no primeiro endpoint TLS. Se ele for um balanceador de carga, precisa fazer o mTLS e encaminhar a identidade, ou então repassar o TCP sem alterações.

Para o OAuth, a RFC 8705 se apoia no mTLS de duas formas: os clientes se autenticam no endpoint de token com um certificado (tls_client_auth), e os tokens de acesso podem ser vinculados a esse certificado (cnf com x5t#S256, a impressão digital SHA-256), de modo que um token roubado é inútil sem a chave. Onde o mTLS não alcança a conexão de ponta a ponta, o DPoP oferece uma vinculação semelhante na camada HTTP, e as assinaturas de mensagens HTTP assinam cada requisição individualmente.

Fontes