Harden API TLS: TLS 1.2/1.3, cipher suites, redirects, and HSTS
For developers operating an API behind nginx, Apache, Node.js, or a load balancer. This walks you through setting TLS 1.2/1.3 only, choosing safe cipher suites, enabling HTTP-to-HTTPS redirects, and adding HSTS without breaking clients or creating redirect loops.
TL;DR — Lock your API to TLS 1.2 and 1.3, prefer server-side modern ciphers, redirect all plain HTTP to HTTPS, and only enable HSTS after HTTPS is clean on every hostname you serve. The most common fix is removing TLS 1.0/1.1, correcting the redirect target to
https://$host$request_uri, and sendingStrict-Transport-Securityonly on HTTPS responses. Reading time: ~5 min
Goal
When you finish, your API endpoint only negotiates TLS 1.2/1.3, presents a valid certificate chain, redirects HTTP to HTTPS without loops, and returns a correct Strict-Transport-Security header on HTTPS responses.
Prerequisites
- Shell access to the API edge host or reverse proxy config, with sudo/root if editing system web server config
- A deployed certificate and private key for your API hostname, plus any intermediate chain file if your server requires it
- A public hostname for the API, for example
api.example.com curl >= 8— check with:
curl --version
openssl >= 3— check with:
openssl version
- If using nginx:
nginx >= 1.22— check with:
nginx -v
- If using Apache HTTPD:
apache2 -vorhttpd -v - The ability to reload your web server or load balancer configuration
Steps
Step 1: Confirm the current TLS and redirect behavior
Run these checks before changing anything.
curl -I http://api.example.com
curl -I https://api.example.com
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_1 </dev/null
You should see HTTP either return 301 or 308 to https://api.example.com/..., HTTPS return 200, 401, or another expected app status, TLS 1.2 connect successfully, and TLS 1.1 fail.
Realistic bad redirect output looks like this:
$ curl -I http://api.example.com
HTTP/1.1 301 Moved Permanently
Location: http://api.example.com/
Server: nginx
That Location: http://... means you have a redirect loop or a downgrade.
Step 2: Set protocol versions and ciphers on nginx
If you terminate TLS on nginx, use this exact server config pattern.
⚠️ Reloading a broken nginx config can take your API offline. Run
nginx -tbeforesystemctl reload nginx.
server {
listen 80;
listen [::]:80;
server_name api.example.com;
return 308 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/ssl/certs/api.example.com.fullchain.pem;
ssl_certificate_key /etc/ssl/private/api.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
sudo nginx -t
sudo systemctl reload nginx
You should see syntax is ok and test is successful, then no connection drop during reload.
Step 3: Set protocol versions and ciphers on Apache HTTPD
If you terminate TLS on Apache instead of nginx, use this vhost pattern.
⚠️ Reloading Apache with the wrong certificate path or syntax will interrupt traffic until fixed.
<VirtualHost *:80>
ServerName api.example.com
Redirect permanent / https://api.example.com/
</VirtualHost>
<VirtualHost *:443>
ServerName api.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/certs/api.example.com.fullchain.pem
SSLCertificateKeyFile /etc/ssl/private/api.example.com.key
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
SSLHonorCipherOrder on
SSLSessionTickets off
Header always set Strict-Transport-Security "max-age=31536000"
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>
sudo apachectl configtest
sudo systemctl reload apache2
On RHEL-like systems, replace apache2 with httpd. Success is Syntax OK.
Step 4: If your app terminates TLS itself, set Node.js TLS minimums
If you run a Node.js HTTPS server directly, set the minimum protocol in code and let TLS 1.3 stay enabled by default.
import https from "node:https";
import fs from "node:fs";
import app from "./app.js";
https.createServer({
key: fs.readFileSync("/etc/ssl/private/api.example.com.key"),
cert: fs.readFileSync("/etc/ssl/certs/api.example.com.fullchain.pem"),
minVersion: "TLSv1.2"
}, app).listen(443);
If you use plain Node behind nginx or Apache, do not also redirect in the app unless you trust X-Forwarded-Proto; otherwise you can create loops.
You should see the app bind normally and external TLS behavior still be controlled by the edge proxy.
Step 5: Add HSTS only after HTTPS is clean
Send HSTS on HTTPS responses for the API hostname after the redirect and certificate chain are already correct.
For nginx, this is already included above:
add_header Strict-Transport-Security "max-age=31536000" always;
For Apache, this is already included above:
Header always set Strict-Transport-Security "max-age=31536000"
Success is an HTTPS response header like this:
$ curl -I https://api.example.com
HTTP/2 200
strict-transport-security: max-age=31536000
Trade-off: do not add includeSubDomains unless every subdomain is already HTTPS-only. Do not add preload unless you intend to meet browser preload requirements and keep that commitment long-term.
Verify it works
Run the full set of checks.
curl -I http://api.example.com
curl -I https://api.example.com
curl --tlsv1.2 -I https://api.example.com
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null | grep -E 'Protocol|Cipher|Verify return code'
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_1 </dev/null
Expected results:
$ curl -I http://api.example.com
HTTP/1.1 308 Permanent Redirect
Location: https://api.example.com/
$ curl -I https://api.example.com
HTTP/2 200
strict-transport-security: max-age=31536000
$ openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null | grep -E 'Protocol|Cipher|Verify return code'
Protocol : TLSv1.2
Cipher : ECDHE-RSA-CHACHA20-POLY1305
Verify return code: 0 (ok)
$ openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_1 </dev/null
CONNECTED(00000003)
...
ssl3_read_bytes:tlsv1 alert protocol version
...
If your API requires auth, 401 Unauthorized on HTTPS is still a pass here; the TLS layer is what you are verifying.
Common pitfalls
Redirect points back to HTTP
Mistake: using return 301 http://$host$request_uri; or an app-level redirect that ignores X-Forwarded-Proto.
Symptom: curl -I http://api.example.com returns Location: http://api.example.com/... and browsers show too many redirects.
Fix: replace the redirect target with https://$host$request_uri at the edge proxy and pass X-Forwarded-Proto https upstream.
HSTS sent before HTTPS is valid everywhere
Mistake: enabling Strict-Transport-Security before fixing certificate chain, hostname coverage, or subdomains.
Symptom: browsers keep forcing HTTPS and users cannot click through certificate errors after the first visit.
Fix: remove HSTS temporarily, fix cert and redirect issues, then re-enable with max-age=31536000; avoid includeSubDomains until all subdomains are ready.
Missing intermediate certificate in the chain
Mistake: serving only the leaf certificate instead of the full chain.
Symptom: openssl s_client connects, but some clients fail with errors like unable to get local issuer certificate or curl: (60) SSL certificate problem.
Fix: point ssl_certificate or SSLCertificateFile to the full chain PEM, not the leaf-only PEM.
TLS 1.0/1.1 still enabled on the load balancer, not the app server
Mistake: hardening nginx or Apache on the instance while TLS actually terminates at a cloud load balancer or CDN.
Symptom: openssl s_client -tls1_1 still succeeds from the public internet even though local config looks correct.
Fix: change the public listener policy on the actual TLS terminator to allow only TLS 1.2 and 1.3.
Breaking old clients by removing TLS 1.2
Mistake: trying to force TLS 1.3 only.
Symptom: some enterprise clients, older SDKs, or Java runtimes fail handshakes with generic handshake_failure errors.
Fix: keep TLSv1.2 TLSv1.3; remove only TLS 1.0 and 1.1 unless you have a measured client inventory proving 1.3-only is safe.
Redirect status code changes request semantics
Mistake: using 301 or 302 for API endpoints that receive POST, PUT, or PATCH.
Symptom: some clients retry as GET after redirect, causing auth or method errors.
Fix: use 308 for HTTP-to-HTTPS redirects on APIs so the method and body are preserved.
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