Local mTLS adoption with Nginx and an FNMT certificate
This document systematizes the procedure to deploy, in a local development environment, a mutual TLS (mTLS) authentication architecture using Nginx. The goal is to simulate the behaviour of an AWS Application Load Balancer (ALB) which, upon terminating the TLS traffic and validating the client certificate, forwards certain metadata to a Spring Boot back-end. This way, the developer can test the integration with real certificates from the Spanish Fábrica Nacional de Moneda y Timbre (FNMT) without deploying any cloud infrastructure.
Throughout the guide two certificate profiles of different natures are generated:
a server certificate self-signed by a local certification authority, needed for
the browser to accept https://localhost:8443, and a trust bundle with the FNMT
authorities, which Nginx uses to validate the client's personal certificate. The
separation between the two is conceptually relevant: the server certificate
resolves the local domain, while the trust bundle resolves the citizen's identity.
1. Folder structure of the demo project
It is recommended to create a separate directory grouping the certificates, the Nginx configuration and the container orchestrator. The final layout should be:
mtls-local-demo/
├── certs/
│ ├── local-ca.crt
│ ├── local-ca.key
│ ├── localhost.crt
│ ├── localhost.key
│ ├── localhost.csr
│ ├── localhost.ext
│ └── fnmt-client-trust-bundle.pem
├── nginx/
│ └── nginx.conf
└── docker-compose.yml
Create the base directory with the usual OS commands:
mkdir mtls-local-demo
cd mtls-local-demo
2. Generate the local HTTPS certificate for Nginx
Although the client certificate will be the FNMT one, Nginx requires a valid
server certificate to expose the https://localhost:8443 endpoint. This
certificate does not belong to the FNMT; it only ensures the browser can establish
an HTTPS connection to the local host.
2.1. Create a local certification authority
First generate the certs directory and then the local CA key pair:
mkdir -p certs
cd certs
openssl genrsa -out local-ca.key 4096
openssl req -x509 \
-new \
-nodes \
-key local-ca.key \
-sha256 \
-days 3650 \
-out local-ca.crt \
-subj "/CN=Local Test CA"
The result is a test root certificate, valid for ten years, to be imported later into the OS or browser trust store.
2.2. Generate the server certificate for localhost
Next, create the server key pair and the corresponding signing request:
openssl genrsa -out localhost.key 2048
openssl req -new \
-key localhost.key \
-out localhost.csr \
-subj "/CN=localhost"
For modern browsers to accept the certificate, you must include subject
alternative names (SAN) covering both localhost and the IP 127.0.0.1. Define
the file localhost.ext with the following content:
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=@alt_names
[alt_names]
DNS.1=localhost
IP.1=127.0.0.1
Sign the certificate with the local CA created above:
openssl x509 -req \
-in localhost.csr \
-CA local-ca.crt \
-CAkey local-ca.key \
-CAcreateserial \
-out localhost.crt \
-days 365 \
-sha256 \
-extfile localhost.ext
Then return to the project root:
cd ..
3. Trusting the local CA
To avoid the browser showing security warnings when accessing
https://localhost:8443, the certs/local-ca.crt certificate must be added to
the system trust store.
- On macOS: import
local-ca.crtinto Keychain Access and mark it as Always Trust. - On Windows: open
certmgr.mscand import the certificate into Trusted Root Certification Authorities. - On Firefox: if the browser uses its own certificate store, import it from Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import.
This step concerns only the local server certificate and is unrelated to the FNMT trust chain, configured in the next section.
4. Preparing the FNMT trust bundle
In order to help you to get AC's certificates to test, you can find under infrastructure/resources path in GitHub the following files:
ac_raiz_fnmt_g2.pem: The root authority.ac_usuarios_g2.pem: The intermediate authority .fnmt-client-trust-bundle.pem: The final bundle you need to create the trust store.
Nginx must know which issuing certification authorities are valid for client
certificates. The output of this section is the file
certs/fnmt-client-trust-bundle.pem, which contains exclusively public
certificates of the FNMT chain. It must not include the citizen's personal
certificate nor their private key.
4.1. Identifying the trust chain
For a typical citizen personal certificate, the chain follows this hierarchy:
AC Raíz FNMT-RCM G2
└── AC Usuarios G2
└── Citizen's FNMT personal certificate
Nevertheless, verify the real chain of the personal certificate, since other subordinate entities such as AC Representación G2 or AC Sector Público G2 may be involved depending on the certificate type.
4.2. Converting .cer formats to PEM
Certificates downloaded from the FNMT may be distributed in DER or PEM format. To check the format use:
openssl x509 -in ac_usuarios_g2.cer -text -noout
If reading it errors, the file is DER-encoded and must be converted:
openssl x509 \
-inform DER \
-in ac_usuarios_g2.cer \
-out ac_usuarios_g2.pem
Apply the same procedure to the root when needed:
openssl x509 \
-inform DER \
-in ac_raiz_fnmt_g2.cer \
-out ac_raiz_fnmt_g2.pem
4.3. Building the final bundle
Once the certificates are in PEM format, concatenate them into a single file. The recommended order places the root first and the intermediate authority second, although Nginx is flexible about the order:
cat ac_raiz_fnmt_g2.pem ac_usuarios_g2.pem > certs/fnmt-client-trust-bundle.pem
The final file should look like:
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
Including only authority certificates ensures Nginx can validate the client certificate's signature without exposing the citizen's sensitive information.
5. Configuring Nginx with mTLS
Create the config directory and, inside it, the nginx/nginx.conf file with the
following content:
events {}
http {
server {
listen 8443 ssl;
server_name localhost;
# Local HTTPS server certificate for Nginx
ssl_certificate /etc/nginx/certs/localhost.crt;
ssl_certificate_key /etc/nginx/certs/localhost.key;
# Accepted CAs for client certificates.
# This is the FNMT chain.
ssl_client_certificate /etc/nginx/certs/fnmt-client-trust-bundle.pem;
# For initial testing you may use optional.
# Once the flow is validated, switch it to on.
ssl_verify_client on;
ssl_verify_depth 4;
# Reasonable TLS for local development
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://host.docker.internal:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Headers simulating those later received from the AWS ALB.
# Nginx exposes client certificate variables such as:
# $ssl_client_s_dn, $ssl_client_i_dn, $ssl_client_serial,
# $ssl_client_verify and $ssl_client_escaped_cert.
proxy_set_header X-Amzn-Mtls-Clientcert-Subject $ssl_client_s_dn;
proxy_set_header X-Amzn-Mtls-Clientcert-Issuer $ssl_client_i_dn;
proxy_set_header X-Amzn-Mtls-Clientcert-Serial-Number $ssl_client_serial;
proxy_set_header X-Amzn-Mtls-Clientcert-Verify $ssl_client_verify;
proxy_set_header X-Amzn-Mtls-Clientcert-Leaf $ssl_client_escaped_cert;
}
}
}
The ssl_verify_client on directive forces the client to present a certificate
that chains to one of the authorities in fnmt-client-trust-bundle.pem. The
X-Amzn-Mtls-Clientcert-* headers reproduce the AWS ALB's behaviour of forwarding
the validated certificate's attributes to the back-end, letting the Spring Boot
back-end implement the same authorization logic both locally and in the cloud.
6. Orchestrating the container with Docker Compose
In the project root create the docker-compose.yml file:
services:
nginx-mtls:
image: nginx:latest
container_name: nginx-mtls-local
ports:
- "8443:8443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
extra_hosts:
- "host.docker.internal:host-gateway"
The service mounts the certificates and configuration read-only, and exposes the
container's port 8443 on the same host port. The extra_hosts directive ensures
host.docker.internal resolves to the host gateway, allowing Nginx to forward
requests to the Spring Boot back-end running at localhost:8080.
Bring up the container with:
docker compose up
During the first tests it is convenient to keep the terminal open to observe Nginx's output and detect possible certificate validation errors.
7. Verifying Nginx startup
From another terminal, check that Nginx responds on the HTTPS port:
curl -k https://localhost:8443
Since ssl_verify_client is enabled, curl without a client certificate will get
an error. This is the expected behaviour and confirms Nginx is requesting mutual
authentication. The definitive test must be run from a browser with the FNMT
certificate installed.
8. End-to-end test from the browser
With the Spring Boot back-end up at localhost:8080 and the FNMT certificate
installed in the system or browser, access:
https://localhost:8443/api/core/certificate-verifications
The certificate-verification endpoint is a POST (see
Verify mTLS certificate), so a plain browser
address-bar visit will end in a 405 Method Not Allowed from the back-end — but that is fine here:
what this step validates is the TLS handshake and certificate selector, which happen before any
HTTP method reaches the back-end. The expected flow is:
- The browser establishes the HTTPS connection to
localhost:8443. - Nginx requests the client certificate.
- The browser shows the certificate selector.
- The user selects their personal FNMT certificate.
- Nginx validates the certificate chain against
fnmt-client-trust-bundle.pem. - Nginx forwards the request to the Spring Boot back-end at
localhost:8080, including the configured headers.
To exercise the full endpoint (issuing a verification token) send an authenticated POST with the
certificate, e.g. curl -k --cert-type P12 --cert your-cert.p12:pass -X POST -H "Authorization: Bearer <JWT>" https://localhost:8443/api/core/certificate-verifications.
If Nginx rejects the certificate, the most common causes are:
- The correct intermediate authority is missing from
fnmt-client-trust-bundle.pem. - The FNMT certificate is not available to the browser used.
- Firefox uses its own certificate store and requires an explicit import.
- The
ssl_verify_depthvalue is insufficient for the full chain. - The certificate is expired or lacks the
clientAuthextended usage.