Security Hardening
Learning Focus
Leave this lesson with a working understanding of security hardening that you can apply immediately in production.
Multiple overlapping security layers for Nginx. Work through them in order.
Layer 1: Firewall — Lock Ports First
# UFW (Ubuntu/Debian)
sudo ufw allow 22/tcp # SSH — ALWAYS first
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
sudo ufw status numbered
# firewalld (AlmaLinux/Rocky)
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
Layer 2: TLS Configuration
In your server block
listen 443 ssl http2;
ssl_certificate /etc/ssl/certs/example.com.crt;
ssl_certificate_key /etc/ssl/private/example.com.key;
# Protocols — TLS 1.2 + 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher suites (TLS 1.2)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
# Session reuse (reduces handshake overhead)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off; # Disable for forward secrecy
# OCSP Stapling (reduces cert validation round-trip)
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
Verify TLS
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | grep "Protocol"
openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -enddate
Layer 3: HTTP Security Headers
In server block — apply to ALL responses
# HSTS — tell browsers to only use HTTPS (add AFTER HTTPS is confirmed working)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# XSS protection (legacy)
add_header X-XSS-Protection "1; mode=block" always;
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions policy
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content Security Policy (adjust per app)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
# Remove server header
server_tokens off;
add_header Inheritance
add_header in a child location block removes all parent add_header directives. Set all security headers in the server block to ensure they apply everywhere.
Verify Headers
curl -I https://example.com/ | grep -iE "Strict-Transport|X-Frame|X-Content|Content-Security"
Layer 4: Rate Limiting
In http block (nginx.conf)
# Define rate limit zones
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;
In server block
# Apply general rate limit
limit_req zone=general burst=20 nodelay;
# Strict limit on login endpoint
location = /wp-login.php {
limit_req zone=login burst=5 nodelay;
include fastcgi.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
# Connection limit per IP
limit_conn perip 20;
# Return 429 Too Many Requests (instead of default 503)
limit_req_status 429;
limit_conn_status 429;
Layer 5: IP Access Control
# Allow specific IPs, deny all others
location /admin {
allow 203.0.113.10;
allow 192.168.1.0/24;
deny all;
}
# Allow all, deny specific bad actor
deny 198.51.100.5;
allow all;
Layer 6: Request Filtering
# Block hidden files and directories (.git, .env, .htaccess)
location ~ /\. {
deny all;
return 404;
}
# Block sensitive file extensions
location ~* \.(env|log|ini|bak|sql|sh|conf|htpasswd|key|pem)$ {
deny all;
return 404;
}
# Block bad HTTP methods (only allow GET, POST, HEAD)
if ($request_method !~ ^(GET|POST|HEAD)$) {
return 405;
}
# Block requests without a Host header (scanners)
if ($host = "") {
return 444; # 444 = close connection with no response
}
# Block common scanner user agents
if ($http_user_agent ~* (sqlmap|nikto|dirbuster|masscan|zgrab)) {
return 403;
}
Layer 7: Cloudflare Origin Lockdown
When using Cloudflare, block all connections to origin that don't come from Cloudflare:
In http block — /etc/nginx/conf.d/cloudflare-ips.conf
# Cloudflare IPv4 ranges (update periodically)
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
# Cloudflare IPv6
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header CF-Connecting-IP;
Security Audit Commands
# 1. Check TLS version
openssl s_client -connect example.com:443 2>&1 | grep "Protocol"
# 2. Check for TLS 1.0/1.1 (should fail)
openssl s_client -connect example.com:443 -tls1 2>&1 | grep "alert handshake"
# 3. Verify HSTS header
curl -I https://example.com/ | grep Strict
# 4. Check .env is blocked
curl -I https://example.com/.env
# Should return 403 or 404
# 5. Check .git is blocked
curl -I https://example.com/.git/
# Should return 403 or 404
# 6. Verify server version hidden
curl -I https://example.com/ | grep Server
# Should return: Server: nginx (no version number)
# 7. Rate limit test
for i in {1..20}; do curl -s -o /dev/null -w "%{http_code}\n" https://example.com/wp-login.php; done
# Should see 429 responses after the burst limit