Lock down legacy admin UIs with mTLS, IP allowlists, and a reverse proxy
For developers who have to expose an administrative interface that cannot do SSO, MFA, or modern auth. This shows how to put the interface behind an nginx reverse proxy with TLS, optional client certificates, IP allowlisting, and header hardening so access is restricted without changing the legacy app.
TL;DR — If the admin interface cannot do modern authentication, do not expose it directly. Put it behind a reverse proxy that terminates TLS, restricts access by source IP and/or mutual TLS, forwards only to the private backend, and returns 403 before the legacy app ever sees unauthorized traffic. Reading time: ~5 min
Goal
When you are done, your legacy administrative interface is reachable only through an nginx reverse proxy over HTTPS, unauthorized clients get 403 Forbidden or a TLS client-certificate failure, and the backend admin service is no longer directly exposed to the internet.
Prerequisites
- SSH access with sudo on a Linux host that will run nginx
- A DNS name for the admin interface, for example
admin.example.com - Control of your DNS provider dashboard (for example, your provider's DNS records page)
nginx >= 1.22— check with:
nginx -v
openssl >= 3.0— check with:
openssl version
curl >= 7.88— check with:
curl --version | head -n1
- The private address of the legacy admin app, for example
http://10.0.20.15:8080 - At least one trusted public source IP or CIDR to allow, for example
203.0.113.14/32 - Port 443 open to the proxy host; port 80 optional only for ACME HTTP-01 validation
- A TLS certificate for
admin.example.comalready issued, or the ability to place certificate files on disk at/etc/nginx/tls/admin.example.com.crtand/etc/nginx/tls/admin.example.com.key
Steps
Step 1: Point DNS at the reverse proxy
In your DNS provider's dashboard, create this record:
| Type | Name | Value | TTL |
|---|---|---|---|
| A | admin | <PUBLIC_PROXY_IP> | 300 |
Success looks like dig returning your proxy IP:
dig +short admin.example.com A
198.51.100.24
Step 2: Confirm the legacy admin app is reachable only from the proxy host
From the proxy host, test the backend directly:
curl -sS -o /dev/null -w '%{http_code} %{remote_ip}:%{remote_port}\n' http://10.0.20.15:8080/
200 10.0.20.15:8080
Success is an HTTP status from the backend. If this times out, fix routing/firewall before continuing.
⚠️ If you change backend firewall rules in this step, you can lock yourself out of the admin app. Keep an existing SSH session open and test from the proxy host before removing any old allow rules.
If you control the backend firewall, restrict it to the proxy host. On the backend host with ufw, replace 198.51.100.24 with the proxy host's source IP:
sudo ufw allow from 198.51.100.24 to any port 8080 proto tcp
sudo ufw deny 8080/tcp
sudo ufw status numbered
Success is a rule list showing an allow for the proxy IP and a deny for other sources.
Step 3: Create a private CA and one client certificate for mTLS
If you want strong device-bound access control, issue client certificates and require them at nginx. Run these on a secure workstation, not on the public proxy:
mkdir -p mtls && cd mtls
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/CN=Admin Access CA"
openssl genrsa -out alice-admin.key 2048
openssl req -new -key alice-admin.key -out alice-admin.csr -subj "/CN=alice-admin"
openssl x509 -req -in alice-admin.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out alice-admin.crt -days 825 -sha256
openssl pkcs12 -export -out alice-admin.p12 -inkey alice-admin.key -in alice-admin.crt -certfile ca.crt -passout pass:changeit
Success is these files existing: ca.crt, alice-admin.p12, alice-admin.crt, alice-admin.key.
Copy only ca.crt to the proxy host:
scp ca.crt admin@admin-proxy:/tmp/ca.crt
ssh admin@admin-proxy 'sudo install -o root -g root -m 0644 /tmp/ca.crt /etc/nginx/ca/admin-access-ca.crt'
Success is the CA file present at /etc/nginx/ca/admin-access-ca.crt.
Step 4: Install the nginx site that enforces TLS, IP allowlisting, and mTLS
Create the nginx config on the proxy host:
sudo mkdir -p /etc/nginx/tls /etc/nginx/ca
sudo tee /etc/nginx/sites-available/admin.example.com >/dev/null <<'NGINX'
server {
listen 80;
listen [::]:80;
server_name admin.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name admin.example.com;
ssl_certificate /etc/nginx/tls/admin.example.com.crt;
ssl_certificate_key /etc/nginx/tls/admin.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_client_certificate /etc/nginx/ca/admin-access-ca.crt;
ssl_verify_client optional;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
proxy_hide_header X-Powered-By;
location / {
allow 203.0.113.14/32;
deny all;
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://10.0.20.15:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Connection "";
proxy_read_timeout 60s;
}
}
NGINX
sudo ln -sf /etc/nginx/sites-available/admin.example.com /etc/nginx/sites-enabled/admin.example.com
sudo nginx -t
sudo systemctl reload nginx
Success is:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Step 5: Import the client certificate on the admin user's machine
On macOS, import the .p12 into the login keychain:
security import alice-admin.p12 -k ~/Library/Keychains/login.keychain-db -P changeit -T /Applications/Safari.app -T /Applications/Google\ Chrome.app
On Windows or Linux, import alice-admin.p12 into the browser or OS certificate store using the local certificate import tool.
Success is the browser showing a client certificate prompt or automatically presenting alice-admin when visiting the site.
Step 6: Test the request path before giving users the URL
Use this flow as the expected request path:
View as text
[Admin Browser] ---- 1. HTTPS + client cert ----> [nginx Proxy] [nginx Proxy] ---- 2. HTTP on private net ----> [Legacy Admin] [nginx Proxy] <---- 3. 200/302/401 app response ---- [Legacy Admin] [Admin Browser] <---- 4. proxied response -------- [nginx Proxy]
From a machine without the client cert or from a non-allowed IP:
curl -k -I https://admin.example.com/
HTTP/2 403
server: nginx/1.24.0
content-type: text/html
content-length: 153
strict-transport-security: max-age=31536000; includeSubDomains
From an allowed IP with the client cert and key files:
curl --cert alice-admin.crt --key alice-admin.key -I https://admin.example.com/
HTTP/2 200
server: nginx/1.24.0
content-type: text/html; charset=utf-8
strict-transport-security: max-age=31536000; includeSubDomains
x-frame-options: DENY
Success is 403 for unauthorized access and the backend's normal 200, 302, or login page for authorized access.
Verify it works
Run all three checks:
dig +short admin.example.com A
curl -I http://admin.example.com/
curl --cert alice-admin.crt --key alice-admin.key -I https://admin.example.com/
Expected results:
198.51.100.24
HTTP/1.1 301 Moved Permanently
Location: https://admin.example.com/
HTTP/2 200
Also verify the backend is not directly exposed from an external machine:
curl -m 5 -I http://198.51.100.24:8080/
Expected result is connection failure or timeout, not an HTTP status:
curl: (7) Failed to connect to 198.51.100.24 port 8080 after 5 ms: Connection refused
Common pitfalls
Client cert is optional in nginx, but you forgot the explicit deny
Mistake: using ssl_verify_client optional; without checking $ssl_client_verify.
Symptom: allowed source IPs can reach the app without presenting a client certificate.
Fix: keep this exact line inside location /:
if ($ssl_client_verify != SUCCESS) { return 403; }
You allowed the CDN or load balancer IPs instead of the real admin source IPs
Mistake: putting upstream proxy ranges in allow when nginx sees the immediate client as something else.
Symptom: every request gets 403 even from the office or VPN.
Fix: replace allow 203.0.113.14/32; with the actual source CIDR nginx sees in access logs, or correctly configure real-IP handling before using allowlists.
Backend generates bad redirects to its private address
Mistake: the app builds absolute URLs from its local bind address.
Symptom: curl -I https://admin.example.com/ returns a Location: http://10.0.20.15:8080/login header.
Fix: keep proxy_set_header Host $host; and proxy_set_header X-Forwarded-Proto https;, then set the app's external base URL to https://admin.example.com if the app supports it.
Certificate chain file is incomplete
Mistake: installing only the leaf cert in /etc/nginx/tls/admin.example.com.crt when your CA requires intermediates.
Symptom: browsers show certificate trust errors, while nginx -t still passes.
Fix: replace admin.example.com.crt with the full certificate chain file provided by your CA.
DNS still points at the old host
Mistake: updating nginx and testing the wrong server because DNS did not change or local cache is stale.
Symptom: curl -I https://admin.example.com/ returns headers from the old stack, not nginx.
Fix: run dig +short admin.example.com A; if it is wrong, fix the A record; if it is right, flush local DNS cache or test with curl --resolve admin.example.com:443:198.51.100.24 https://admin.example.com/ -I.
You left the backend port open to the internet
Mistake: deploying the reverse proxy but not restricting direct access to :8080.
Symptom: scanners still hit the legacy login page directly, bypassing mTLS and allowlists.
Fix: add a host firewall rule on the backend to allow only the proxy host and deny all other sources on the admin port.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI