Configure mTLS between Nginx and a Node.js app using HashiCorp Vault for automatic certificate issuance

Redes & Segurança · By · Published on · Updated on · 11 min read

detailed spiderweb between branches with a small spider in the center
Visual metaphor for networks and connections in mTLS architectures Photo: kareni / Pixabay

This tutorial shows how to configure mutual TLS (mTLS) between Nginx and a Node.js application using HashiCorp Vault to automatically issue and renew certificates. You will create an internal PKI, configure AppRoles, use the Vault Agent to render keys and certs on disk, and require mutual verification on Nginx’s HTTPS upstream.

Architecture and goal of this guide

We will set up mTLS for the internal link where Nginx acts as the TLS client and Node.js is the TLS server. Vault will handle issuance and renewal through the Vault Agent, so Nginx presents a client certificate that Node verifies, and Nginx validates the Node server certificate.

This is a hands-on guide to configure mTLS between Nginx and a Node.js app using HashiCorp Vault for automated certificate issuance.

If you need mTLS between microservices running in Docker, also consult the step-by-step guide titled "mTLS in Docker Compose (Internal PKI)".

Component Certificates/Files Purpose
Vault (PKI) Root + intermediate CA Sign and renew certificates
Nginx (client) /etc/nginx/certs/nginx.crt and .key Present client cert to Node.js
Node.js (server) /etc/node/certs/server.crt and .key Serve HTTPS and require client cert
Shared CA /etc/*/certs/ca.pem Trust the certificate chain

Prerequisites and warnings

You need a Linux server with Nginx, Node.js (>= 18), Vault, the Vault CLI and OpenSSL installed. Use internal hostnames (for example nginx.local and node.local) resolving to the appropriate IPs.

Ports used in this guide: Vault (8200), Node (8443) and Nginx (80/443). Do not expose Vault without authentication and TLS. To harden the server, see the VPS security checklist and adjust your firewall as needed.

For an alternative reverse proxy, see the Traefik vs Nginx comparison.

  • sudo/root access
  • Text editor (nano/vi)
  • Synchronized clock (NTP) to avoid certificate validity errors
  • Disk space for keys/certs with restricted permissions

1) Start Vault and configure the PKI (root + intermediate + role)

For lab purposes we use Vault in dev mode to speed up the setup. Never use dev mode in production. In production you must initialize and unseal Vault properly and enable TLS for Vault access.

We will create a root CA, an intermediate CA and a role that allows issuing both client and server certificates.

# 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 and AppRole for automatic issuance

We use AppRole so the Vault Agent can authenticate and issue certificates without human intervention. Each component (Nginx and Node) gets its own AppRole; both use the same minimal policy to issue via the "svc" role.

Keep role_id and secret_id out of version control and with file permissions set to 0600. If a secret_id leaks, revoke it immediately.

# 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 for Nginx and the Node.js app

The Vault Agent performs auto_auth via AppRole and renders keys and certs on disk, renewing them before expiration. Define destinations, file permissions and a reload command to run when files change.

Ensure only the Nginx and Node processes can read their private keys (0600). Never leave .key files readable by other users.

# 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) Node.js code that requires mTLS from Nginx

The Node.js server exposes HTTPS on port 8443 and requires a client certificate. It trusts the CA from your intermediate PKI. Adjust paths if you used different directories.

In production, run your app as a service and handle reloads when keys rotate. Some runtimes support graceful reloads without dropping connections.

// 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 as a reverse proxy with mTLS to the upstream

Nginx will act as the TLS client for the upstream Node.js server. It will present its client certificate and verify the server certificate. Use SNI to match the Node certificate CN/SAN (node.local).

For public HTTPS at the edge, integrate ACME/Let’s Encrypt afterwards.

# /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) How to confirm it worked and troubleshooting

After following this guide, verify the presence of the generated files. Start the Node server and reload Nginx. Then test the connection directly with openssl and through Nginx with curl. Also check certificate expiration dates to confirm automatic renewal.

Common problems and fixes:

  • x509: unknown authority — check proxy_ssl_trusted_certificate and the CA configured in Node.
  • SNI/CN mismatch — align proxy_ssl_name with the CN/SAN issued for the Node.
  • EACCES on key files — adjust ownership/group and permissions to 0600/0644.
  • Invalid AppRole — regenerate the secret_id and verify role_id/secret_id on the agents.
  • Clock skew — sync NTP to avoid incorrect NotBefore/NotAfter values.
# 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

Frequently asked questions

Can I use Vault dev mode in production?

No. Dev mode is only for lab use: it runs in memory, is unsealed and uses a static token. In production, initialize Vault with persistent storage, enable TLS for Vault access and perform secure unseal with multiple keys.

How do I combine internal mTLS with public HTTPS for clients?

Use mTLS only for the internal Nginx→Node link, and terminate public HTTPS at the Nginx edge using ACME/Let’s Encrypt certificates for external traffic.

How can I restrict which domains the Vault role may issue?

The example uses allow_any_name for simplicity. In real environments, set allowed_domains and allow_subdomains and disable allow_any_name to prevent issuing certificates for names outside your internal domain.

How do I rotate certificates without downtime?

With Vault Agent writing the renewed files, reload Nginx (nginx -s reload) to pick up new material. For Node.js, run it as a service with graceful reload support or restart it behind Nginx to avoid interruptions.

Can I replace AppRole with another auto_auth method?

Yes. You can use Kubernetes, AWS IAM, JWT/OIDC or other methods depending on your environment. Always grant the minimal capabilities (issue on the specific role and read the CA chain) to follow least privilege.

Need to adapt this mTLS setup to your environment?

If you want a review of your PKI, Vault policies and renewal automation for production, describe your setup and we can evaluate the next steps together.

Talk about mTLS and Vault

See also