Configurar mTLS entre Nginx y una aplicación Node.js usando HashiCorp Vault para emisión automática de certificados

Redes & Segurança · Por · Publicado el · Actualizado el · 11 min de lectura

telaraña detallada entre ramas con una pequeña araña en el centro
Metáfora visual para redes y conexiones en arquitecturas mTLS Foto: kareni / Pixabay

Este tutorial muestra cómo configurar mTLS entre Nginx (cliente) y una aplicación Node.js (servidor) usando HashiCorp Vault para emitir y renovar certificados automáticamente. Montarás una PKI interna, crearás AppRoles, usarás Vault Agent para materializar claves y certificados en disco y exigirás verificación mutua en el upstream HTTPS de Nginx.

Arquitectura y objetivo del tutorial

Vamos a configurar mTLS entre Nginx (cliente) y Node.js (servidor) en el enlace interno de proxy. Vault se encargará de la emisión y renovación mediante Vault Agent. De este modo, Nginx verificará el certificado del Node y presentará su propio certificado de cliente, que Node validará.

Este es un tutorial práctico para “Configurar mTLS entre Nginx y una aplicación Node.js usando HashiCorp Vault para emisión automática de certificados”.

Si necesitas mTLS entre microservicios en Docker, también hay un paso a paso con PKI interna en mTLS en Docker Compose (PKI).

Componente Certificados/archivos Finalidad
Vault (PKI) Autoridad raíz + intermedia Firmar y renovar certificados
Nginx (cliente) /etc/nginx/certs/nginx.crt e .key Presentar certificado de cliente a Node.js
Node.js (servidor) /etc/node/certs/server.crt e .key Servir HTTPS y exigir certificado de cliente
CA compartida /etc/*/certs/ca.pem Confiar en la cadena de certificación

Prerequisitos y advertencias

Necesitas un servidor Linux con Nginx, Node.js (>= 18), Vault, el CLI de Vault y OpenSSL instalados. Usa hostnames internos (ej.: nginx.local y node.local) resolviendo a sus IPs.

Configura puertos: Vault (8200), Node (8443) y Nginx (80/443). No expongas Vault sin autenticación y TLS. Para reforzar el servidor, consulta el checklist de seguridad para VPS y ajusta el firewall UFW.

Para un proxy inverso alternativo, consulta la comparativa Traefik vs Nginx.

  • Acceso sudo/root
  • Editor de texto (nano/vi)
  • Reloj sincronizado (NTP) para evitar errores en la validez de certificados
  • Espacio en disco para claves/certs con permisos restringidos

1) Levantar Vault y configurar la PKI (raíz + intermedia + role)

Para fines de laboratorio usaremos Vault en modo dev para acelerar la configuración. Jamás uses el modo dev en producción. En producción inicializa y desempaqueta (unseal) Vault correctamente y aplica TLS en el acceso al servidor de Vault.

Vamos a crear una CA raíz, una intermedia y la role que permitirá emitir certificados de cliente y servidor.

# 1) Inicie o Vault (laboratório)
export VAULT_ADDR="http://127.0.0.1:8200"
nohup vault server -dev -dev-root-token-id=root >/tmp/vault-dev.log 2>&1 &
export VAULT_TOKEN="root"

# 2) Habilite PKI raiz e gere a CA
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki
vault write pki/root/generate/internal common_name="Demo Root CA" ttl=87600h

# 3) Habilite PKI intermediária e gere CSR
vault secrets enable -path=pki_int pki
vault secrets tune -max-lease-ttl=43800h pki_int
vault write -field=csr pki_int/intermediate/generate/internal \
  common_name="Demo Intermediate CA" > /tmp/pki_intermediate.csr

# 4) Assine a intermediária com a raiz e instale
vault write -field=certificate pki/root/sign-intermediate \
  csr=@/tmp/pki_intermediate.csr format=pem_bundle ttl=43800h \
  > /tmp/intermediate.cert.pem
vault write pki_int/intermediate/set-signed \
  certificate=@/tmp/intermediate.cert.pem

# 5) Crie role para emitir certificados de cliente e servidor
vault write pki_int/roles/svc \
  allow_any_name=true \
  key_type=ec key_bits=256 \
  server_flag=true client_flag=true \
  max_ttl=720h require_cn=false

# 6) Opcional: exporte cadeia da CA intermediária
vault read -field=certificate pki_int/ca_chain > /tmp/ca_chain.pem

2) Policies y AppRole para emisión automática

Usaremos AppRole para que Vault Agent autentique y emita certificados sin intervención humana. Cada componente (Nginx y Node) tendrá un AppRole distinto, ambos con la misma policy mínima para emitir mediante la role "svc".

Mantén role_id y secret_id fuera del control de versiones y con permisos 0600. Si se filtra el secret_id, revócalo inmediatamente.

# Policy mínima para emissão pela role "svc"
sudo install -d -m 0750 -o root -g root /etc/vault
cat <<'HCL' | sudo tee /etc/vault/pki-svc.hcl >/dev/null
path "pki_int/issue/svc" {
  capabilities = ["update"]
}
path "pki_int/ca_chain" {
  capabilities = ["read"]
}
HCL

# Crie a policy e habilite AppRole
authEnable=$(vault auth list -format=table | grep approle || true)
[ -z "$authEnable" ] && vault auth enable approle || true
vault policy write pki-svc /etc/vault/pki-svc.hcl

# Crie AppRoles (nginx e node) com a policy
vault write auth/approle/role/nginx token_policies="pki-svc" \
  secret_id_ttl=0 token_ttl=24h token_max_ttl=720h token_num_uses=0
vault write auth/approle/role/node  token_policies="pki-svc" \
  secret_id_ttl=0 token_ttl=24h token_max_ttl=720h token_num_uses=0

# Capture role_id e secret_id em arquivos locais protegidos
vault read  -field=role_id  auth/approle/role/nginx/role-id  | sudo tee /etc/vault/nginx-role-id >/dev/null
vault write -field=secret_id -f auth/approle/role/nginx/secret-id | sudo tee /etc/vault/nginx-secret-id >/dev/null
vault read  -field=role_id  auth/approle/role/node/role-id   | sudo tee /etc/vault/node-role-id  >/dev/null
vault write -field=secret_id -f auth/approle/role/node/secret-id  | sudo tee /etc/vault/node-secret-id  >/dev/null
sudo chmod 600 /etc/vault/*-role-id /etc/vault/*-secret-id

3) Vault Agent para Nginx y para la aplicación Node.js

Vault Agent hará auto_auth por AppRole y renderizará claves y certificados en disco, renovándolos antes de su vencimiento. Define destinos, permisos y un comando de reload cuando los archivos cambien.

Asegura que solo los procesos de Nginx y Node puedan leer sus claves privadas (0600). Nunca dejes .key legible por otros usuarios.

# Diretórios de certificados
sudo install -d -m 0750 -o root -g www-data /etc/nginx/certs
sudo install -d -m 0750 -o root -g node      /etc/node/certs || true

# Config do Vault Agent para o Nginx
cat <<'HCL' | sudo tee /etc/vault/agent-nginx.hcl >/dev/null
vault {
  address = "http://127.0.0.1:8200"
}
auto_auth {
  method "approle" {
    mount_path = "auth/approle"
    config = {
      role_id_file_path   = "/etc/vault/nginx-role-id"
      secret_id_file_path = "/etc/vault/nginx-secret-id"
    }
  }
  sink "file" { config = { path = "/etc/vault/nginx-token" } }
}
cache { use_auto_auth_token = true }

template {
  destination = "/etc/nginx/certs/nginx.key"
  perms       = 0600
  contents    = <<EOT
{{- with secret "pki_int/issue/svc" "common_name=nginx.local" "ttl=720h" -}}
{{ .Data.private_key }}
{{- end -}}
EOT
}

template {
  destination = "/etc/nginx/certs/nginx.crt"
  perms       = 0644
  contents    = <<EOT
{{- with secret "pki_int/issue/svc" "common_name=nginx.local" "ttl=720h" -}}
{{ .Data.certificate }}
{{ range .Data.ca_chain }}{{ . }}
{{ end }}
{{- end -}}
EOT
}

template {
  destination = "/etc/nginx/certs/ca.pem"
  perms       = 0644
  contents    = <<EOT
{{- with secret "pki_int/ca_chain" -}}
{{ .Data.certificate }}
{{- end -}}
EOT
}
HCL

# Config do Vault Agent para o Node.js
cat <<'HCL' | sudo tee /etc/vault/agent-node.hcl >/dev/null
vault {
  address = "http://127.0.0.1:8200"
}
auto_auth {
  method "approle" {
    mount_path = "auth/approle"
    config = {
      role_id_file_path   = "/etc/vault/node-role-id"
      secret_id_file_path = "/etc/vault/node-secret-id"
    }
  }
  sink "file" { config = { path = "/etc/vault/node-token" } }
}
cache { use_auto_auth_token = true }

template {
  destination = "/etc/node/certs/server.key"
  perms       = 0600
  contents    = <<EOT
{{- with secret "pki_int/issue/svc" "common_name=node.local" "ttl=720h" -}}
{{ .Data.private_key }}
{{- end -}}
EOT
}

template {
  destination = "/etc/node/certs/server.crt"
  perms       = 0644
  contents    = <<EOT
{{- with secret "pki_int/issue/svc" "common_name=node.local" "ttl=720h" -}}
{{ .Data.certificate }}
{{ range .Data.ca_chain }}{{ . }}
{{ end }}
{{- end -}}
EOT
}

template {
  destination = "/etc/node/certs/ca.pem"
  perms       = 0644
  contents    = <<EOT
{{- with secret "pki_int/ca_chain" -}}
{{ .Data.certificate }}
{{- end -}}
EOT
}
HCL

# Inicie os agentes (em segundo plano)
nohup vault agent -config=/etc/vault/agent-nginx.hcl >/tmp/agent-nginx.log 2>&1 &
nohup vault agent -config=/etc/vault/agent-node.hcl  >/tmp/agent-node.log  2>&1 &

4) Código Node.js que exige mTLS desde Nginx

El servidor Node.js expone HTTPS en el puerto 8443 y solicita certificado de cliente. Confía en la CA de tu PKI intermedia. Ajusta rutas si usaste directorios distintos.

En producción, ejecuta tu app como servicio y haz reload al rotar claves. Algunos runtimes soportan reload sin cerrar conexiones.

// server.js (Node 18+)
const fs = require('fs');
const https = require('https');

const options = {
  key:  fs.readFileSync('/etc/node/certs/server.key'),
  cert: fs.readFileSync('/etc/node/certs/server.crt'),
  ca:   fs.readFileSync('/etc/node/certs/ca.pem'),
  requestCert: true,
  rejectUnauthorized: true
};

https.createServer(options, (req, res) => {
  if (!req.client.authorized) {
    res.writeHead(401);
    return res.end('Client cert inválido');
  }
  const peer = req.socket.getPeerCertificate();
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end(`Olá, mTLS. CN do cliente: ${peer.subject.CN}`);
}).listen(8443, () => {
  console.log('Node.js mTLS escutando em https://127.0.0.1:8443');
});

5) Nginx como reverse proxy con mTLS hacia el upstream

Nginx actuará como cliente TLS hacia el upstream Node.js. Presentará su certificado de cliente y verificará el del servidor. Usa SNI para que coincida con el CN/SAN del Node (node.local).

Para HTTPS público en el edge, integra ACME/Let's Encrypt después. Consulta la guía de SSL gratis con Let's Encrypt.

# /etc/nginx/conf.d/node-mtls.conf
upstream node_upstream {
  server 127.0.0.1:8443;
}

server {
  listen 80;
  server_name _;

  location / {
    proxy_pass https://node_upstream;

    # mTLS no upstream
    proxy_ssl_name node.local;              # SNI para casar com o cert do Node
    proxy_ssl_server_name on;
    proxy_ssl_verify on;
    proxy_ssl_verify_depth 2;
    proxy_ssl_trusted_certificate /etc/nginx/certs/ca.pem;   # CA que assinou o cert do Node

    # Certificado de cliente apresentado pelo Nginx ao Node
    proxy_ssl_certificate     /etc/nginx/certs/nginx.crt;
    proxy_ssl_certificate_key /etc/nginx/certs/nginx.key;

    # Cabeçalhos usuais
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

6) Cómo confirmar que funcionó y resolución de problemas

Tras completar este tutorial, valida la presencia de los archivos. Inicia Node y recarga Nginx. Luego prueba la conexión directa con openssl y vía Nginx con curl. Verifica también las fechas de expiración para confirmar la renovación automática.

Problemas comunes y correcciones:

  • x509: unknown authority: verifica proxy_ssl_trusted_certificate y la CA en Node.
  • mismatch SNI/CN: alinea proxy_ssl_name con el CN/SAN emitido para Node.
  • EACCES en las claves: ajusta propietarios/grupos y permisos 0600/0644.
  • AppRole inválido: regenera secret_id y confirma role_id/secret_id en los agentes.
  • Skew de reloj: sincroniza NTP para evitar NotBefore/NotAfter incorrectos.
# 1) Confira os arquivos foram emitidos
ls -l /etc/nginx/certs /etc/node/certs

# 2) Suba o servidor Node.js
node /path/para/server.js &> /tmp/node.log &

# 3) Teste mTLS direto contra o Node (apresentando o cert do Nginx)
openssl s_client -connect 127.0.0.1:8443 \
  -cert /etc/nginx/certs/nginx.crt \
  -key  /etc/nginx/certs/nginx.key \
  -CAfile /etc/nginx/certs/ca.pem -quiet <<< "GET / HTTP/1.1\nHost: node.local\n\n"

# 4) Valide a config do Nginx e recarregue
sudo nginx -t && sudo systemctl reload nginx

# 5) Teste via Nginx (HTTP -> Nginx -> mTLS -> Node)
curl -i http://127.0.0.1/

# 6) Cheque a expiração do cert do Node (renovação automática)
openssl x509 -in /etc/node/certs/server.crt -noout -enddate

Preguntas frecuentes

¿Puedo usar el modo dev de Vault en producción?

No. El modo dev es solo para laboratorio: corre en memoria, viene desempaquetado y usa un token estático. En producción inicializa Vault con almacenamiento persistente, habilita TLS en el acceso y realiza un unseal seguro con varias claves.

¿Cómo combinar mTLS interno con HTTPS público para clientes?

Usa mTLS únicamente en el enlace interno Nginx→Node y configura HTTPS público en Nginx con certificados ACME/Let's Encrypt para el tráfico externo. El tutorial de SSL gratis con Let's Encrypt explica los pasos para el edge.

¿Cómo restringir qué dominios pueden emitirse desde la role de Vault?

En el ejemplo usamos allow_any_name para simplificar. En entornos reales define allowed_domains y allow_subdomains, y desactiva allow_any_name. Así evitas la emisión accidental de certificados con nombres fuera de tu dominio interno.

¿Cómo rotar certificados sin downtime?

Con Vault Agent escribiendo nuevos archivos, recarga Nginx (nginx -s reload) para cargar el material renovado. Para Node.js, ejecútalo como servicio con soporte de reload gracioso o reinicio controlado detrás de Nginx para evitar interrupciones.

¿Puedo cambiar AppRole por otro método de auto_auth?

Sí. Puedes usar métodos como Kubernetes, AWS IAM o JWT/OIDC según tu entorno. Lo importante es dar solo las capacidades mínimas (issue en la role específica y lectura de la cadena de CA) siguiendo el principio de menor privilegio.

¿Necesitas adaptar este mTLS a tu entorno?

Si quieres revisar tu PKI, políticas de Vault y automatización de renovación en producción, describe tu escenario y podemos evaluar juntos los próximos pasos.

Hablar sobre mTLS y Vault

Ver también