Tutorial · Infraestrutura

Configurando mTLS no NGINX para certificados ICP-Brasil

Como exigir e validar um certificado digital do cliente (e-CPF ou e-CNPJ) direto no NGINX — as diretivas, o fluxo do handshake, os erros que aparecem em produção e o que costuma virar dor de cabeça depois.

Autenticação por certificado cliente NGINX · OpenSSL · ICP-Brasil Leitura: ~9 min
Neste guia
  1. O que é mTLS e por que a ICP-Brasil usa
  2. Pré-requisitos
  3. Montando a cadeia de ACs confiáveis
  4. Configuração do NGINX (bloco server)
  5. Repassando o certificado ao backend
  6. Erros comuns de handshake
  7. O trabalho que não acaba: manutenção

O que é mTLS e por que a ICP-Brasil usa

No TLS convencional — o cadeado do HTTPS — só o servidor apresenta um certificado. O cliente confirma que está falando com quem diz ser, mas o servidor não faz ideia de quem é o cliente. É o suficiente para navegar num site, não para provar identidade.

O mTLS (TLS mútuo, ou mutual TLS) inverte também o outro lado: durante o handshake, o servidor exige que o cliente apresente um certificado e o valida antes de deixar a conexão prosseguir. É esse mecanismo que permite usar um certificado ICP-Brasil (e-CPF para pessoa física, e-CNPJ para empresa) como prova de identidade forte no login de um sistema — sem senha, sem SMS, com garantia jurídica de quem está do outro lado.

A parte específica da ICP-Brasil é a cadeia de confiança: um certificado e-CPF não é assinado diretamente pela raiz, mas por uma Autoridade Certificadora (AC) intermediária, que por sua vez encadeia até a AC-Raiz da ICP-Brasil. Para o NGINX aceitar um e-CPF, ele precisa conhecer toda a cadeia — a raiz e todas as intermediárias válidas. É aqui que a coisa começa a ficar trabalhosa, como você vai ver.

Pré-requisitos

Montando a cadeia de ACs confiáveis

O NGINX valida o certificado do cliente contra um arquivo único que reúne os certificados das ACs em que você confia. Os certificados oficiais são publicados pelo ITI (Instituto Nacional de Tecnologia da Informação), que opera a AC-Raiz.

Na prática, você baixa os certificados da AC-Raiz e de cada AC intermediária e concatena tudo num só arquivo:

bash — concatenando a cadeia
# cada .crt é uma AC (raiz + intermediárias) baixada do repositório do ITI
cat ac-raiz.crt \
    ac-intermediaria-1.crt \
    ac-intermediaria-2.crt \
    # ... e assim por diante, para cada AC válida
    > icp-brasil-cadeia.pem
Atenção. Esse arquivo não é estático. A ICP-Brasil adiciona, renova e revoga ACs ao longo do tempo. Uma AC que faltar no seu .pem significa que todos os certificados emitidos por ela serão rejeitados no handshake — e o usuário legítimo simplesmente não consegue entrar. Guarde essa frase: manter a cadeia completa e atualizada é um processo contínuo, não uma configuração de uma vez.

Configuração do NGINX (bloco server)

Com a cadeia montada, o bloco server fica assim. As três diretivas que fazem o mTLS acontecer são ssl_client_certificate, ssl_verify_client e ssl_verify_depth:

nginx.conf
server {
    listen 443 ssl;
    server_name app.seudominio.com.br;

    # certificado do SERVIDOR (o HTTPS normal)
    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    # ---- mTLS: validação do certificado do CLIENTE ----
    # arquivo com a cadeia de ACs da ICP-Brasil que você confia
    ssl_client_certificate /etc/nginx/ssl/icp-brasil-cadeia.pem;

    # 'on' = exige e rejeita se inválido
    # 'optional' = pede, mas deixa passar (você decide no backend)
    ssl_verify_client on;

    # profundidade da cadeia: raiz -> intermediária -> cliente
    # na ICP-Brasil normalmente 2 é o mínimo seguro
    ssl_verify_depth 2;

    location / {
        proxy_pass http://127.0.0.1:8000;

        # repassa os dados do certificado ao backend (ver próxima seção)
        proxy_set_header X-SSL-Verify  $ssl_client_verify;
        proxy_set_header X-SSL-Subject $ssl_client_s_dn;
        proxy_set_header X-SSL-Cert    $ssl_client_escaped_cert;
    }
}

Sobre ssl_verify_client, vale entender a diferença prática:

Dica. Em aplicações reais, optional quase sempre é preferível: você consegue mostrar uma tela amigável de "certificado não reconhecido" em vez de deixar o navegador cuspir um erro de conexão que assusta o usuário.

Testando a configuração

bash
# valida a sintaxe antes de recarregar
nginx -t

# recarrega sem derrubar conexões ativas
systemctl reload nginx

Repassando o certificado ao backend

O NGINX faz a validação, mas quem decide "esse e-CPF é do usuário João, libera a sessão" é a sua aplicação. Por isso os proxy_set_header acima: eles injetam nos cabeçalhos o resultado da verificação e os dados do certificado.

As variáveis mais úteis que o NGINX expõe:

No backend, você lê esses cabeçalhos e segue com a criação da sessão. Um exemplo mínimo em Python:

python — lendo o resultado no backend
def autenticar(request):
    verify = request.headers.get("X-SSL-Verify")
    if verify != "SUCCESS":
        return erro("Certificado não reconhecido", 401)

    subject = request.headers.get("X-SSL-Subject")
    # aqui vem a parte chata: extrair CPF/CNPJ e nome do Subject DN,
    # que na ICP-Brasil ficam codificados em campos e OIDs específicos
    dados = parse_icp_brasil(subject)
    return criar_sessao(dados)
O detalhe que consome tempo. Extrair o CPF, o CNPJ e o nome do titular de um certificado ICP-Brasil não é trivial: esses dados ficam em OIDs proprietários dentro de extensões do certificado, não em campos padrão do X.509. O parse_icp_brasil() do exemplo acima esconde uma boa dose de trabalho — e muda conforme o tipo (e-CPF vs. e-CNPJ) e a versão do certificado.

Erros comuns de handshake

Se você chegou até aqui, provavelmente vai esbarrar em pelo menos um destes. Os mais frequentes:

Sintoma / erro
Causa provável
400 Bad Request
No required SSL certificate was sent
O ssl_verify_client on exigiu o certificado, mas o navegador não enviou nenhum. O usuário não selecionou o certificado, ou não tem um instalado.
SSL certificate verify failed
(unable to get local issuer)
A AC que emitiu o certificado do cliente não está na sua cadeia .pem. Faltou uma intermediária — o caso mais comum quando a ICP-Brasil adiciona uma AC nova.
verify depth exceeded
O ssl_verify_depth está baixo demais para a cadeia daquele certificado. Aumente o valor.
Handshake falha só em alguns
navegadores / no A3
Diferença de comportamento entre navegadores na negociação do certificado cliente, ou driver do token A3 desatualizado. Notoriamente inconsistente entre Chrome, Firefox e Safari.
Certificado revogado aceito
(ou o contrário)
O NGINX, sozinho, não checa CRL/OCSP da ICP-Brasil por padrão. Validar revogação exige configuração adicional — e manter as CRLs atualizadas.

O trabalho que não acaba: manutenção

Colocar o mTLS de pé, como você viu, é factível numa tarde. O que raramente entra na conta é o que vem depois do deploy — e é aí que mora o custo real:

Nada disso é impossível. Mas é um sistema vivo que precisa de atenção contínua — e que não tem relação nenhuma com o produto que você realmente quer construir. É infraestrutura de identidade, não a sua feature.

Ou pule toda essa camada

Autenticação ICP-Brasil como serviço

O IHub-Auth entrega login por e-CPF e e-CNPJ sem você montar nem manter mTLS. A cadeia ICP-Brasil completa fica atualizada do nosso lado, a validação acontece via verificação server-to-server, e você recebe os dados do titular já estruturados. Integração em minutos — inclua um script, configure seu token, pronto.