Web Server Hardening
A web server is the outermost layer of an application stack โ every public request passes through it before reaching application code. A poorly configured web server leaks information about the stack, accepts dangerous request types, serves content over insecure channels, and fails to instruct browsers on how to protect users. Hardening the web server addresses these issues at the infrastructure layer, independent of the application code running behind it.
This article covers Nginx and Apache HTTP Server, with IIS notes where configuration differs significantly. Examples assume Linux-based deployments.
1. Remove Version and Server Information
The first rule of web server hardening is minimising what you tell attackers about your stack. Default configurations advertise the server software and version in every response header and error page.
Nginx
# nginx.conf
http {
server_tokens off; # Removes version from Server header and error pages
# Server: nginx (instead of Server: nginx/1.25.3)
}
Apache
# httpd.conf or security.conf
ServerTokens Prod # Server: Apache (suppresses OS, module, version)
ServerSignature Off # Removes server info from error pages
IIS
# Remove Server header entirely (IIS 10+)
Import-Module WebAdministration
Set-WebConfigurationProperty -pspath 'MACHINE/WEBROOT/APPHOST' `
-filter 'system.webServer/security/requestFiltering' `
-name 'removeServerHeader' -value 'True'
Additionally, suppress technology-revealing headers added by application frameworks:
# Remove X-Powered-By header (PHP, ASP.NET reveal themselves here)
fastcgi_hide_header X-Powered-By;
proxy_hide_header X-Powered-By;
# Apache โ requires mod_headers
Header unset X-Powered-By
Header always unset X-Powered-By
2. TLS Configuration
Correct TLS configuration is foundational. Refer to the SSL/TLS Certificates and Encryption articles for certificate selection and key management. The configuration below covers the server-side protocol and cipher settings.
Nginx โ Recommended TLS Configuration
server {
listen 443 ssl http2;
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 minimum; TLS 1.3 preferred
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher suites โ forward secrecy, authenticated encryption only
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers off; # Let TLS 1.3 clients negotiate freely
# Session resumption โ performance + security balance
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # Disable tickets; forward secrecy degrades with long-lived ticket keys
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/certs/ca-bundle.crt;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Diffie-Hellman parameters (for DHE cipher suites)
ssl_dhparam /etc/ssl/dhparam.pem; # Generate: openssl dhparam -out /etc/ssl/dhparam.pem 3072
}
Apache โ Recommended TLS Configuration
SSLEngine on
SSLCertificateFile /etc/ssl/certs/example.com.crt
SSLCertificateKeyFile /etc/ssl/private/example.com.key
SSLCACertificateFile /etc/ssl/certs/ca-bundle.crt
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
SSLHonorCipherOrder off
SSLSessionTickets off
SSLUseStapling on
SSLStaplingCache shmcb:/run/apache2/ssl_stapling(32768)
SSLStaplingStandardCacheTimeout 3600
Enforce HTTPS โ Redirect All HTTP
# Nginx โ redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# Apache
<VirtualHost *:80>
RewriteEngine On
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
</VirtualHost>
3. HTTP Security Headers
Security headers instruct the browser on how to handle your content. They are the primary defence against a broad category of client-side attacks. Set them as close to the edge as possible โ ideally at the web server layer so they apply to every response regardless of the application behind it.
Strict-Transport-Security (HSTS)
Tells browsers to only connect via HTTPS for the specified duration. Prevents SSL stripping and protocol downgrade attacks.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
| Parameter | Value | Meaning |
|---|---|---|
max-age |
31536000 |
1 year โ browsers cache this policy |
includeSubDomains |
โ | Applies to all subdomains โ only add if all subdomains serve HTTPS |
preload |
โ | Allows submission to browser HSTS preload lists โ irreversible; test thoroughly first |
Warning:
includeSubDomainswill break any subdomain still serving HTTP. Audit all subdomains before enabling.
X-Content-Type-Options
Prevents browsers from MIME-sniffing a response away from the declared Content-Type. Stops attacks where a file upload of a JPEG containing HTML is executed as HTML.
add_header X-Content-Type-Options "nosniff" always;
Always set this. There is no valid reason to omit it.
X-Frame-Options
Controls whether the page can be embedded in an <iframe> โ prevents clickjacking attacks where an attacker overlays your page in a transparent iframe on their malicious site.
add_header X-Frame-Options "DENY" always;
# Or SAMEORIGIN if your application legitimately iframes itself
Superseded by CSP frame-ancestors but keep both for compatibility with older browsers.
Referrer-Policy
Controls how much referrer information is sent with outbound requests. Prevents leaking internal URLs, session tokens in query strings, or user paths to third parties.
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
| Policy | Behaviour |
|---|---|
no-referrer |
No referrer sent at all |
strict-origin-when-cross-origin |
Full URL for same-origin; only origin for cross-origin; nothing for HTTPโHTTPS downgrades โ recommended default |
same-origin |
Referrer sent only for same-origin requests |
unsafe-url |
Always sends full URL โ never use this |
Permissions-Policy
Restricts which browser features and APIs the page and embedded third-party content can use. Limits the impact of compromised third-party scripts.
add_header Permissions-Policy "
accelerometer=(),
camera=(),
geolocation=(),
gyroscope=(),
magnetometer=(),
microphone=(),
payment=(),
usb=()
" always;
Deny all features your application does not use. If your site uses geolocation, allow it explicitly: geolocation=(self).
Content-Security-Policy (CSP)
CSP is the most powerful and most complex security header. It defines a whitelist of trusted sources for scripts, styles, images, fonts, and connections โ the browser blocks everything else.
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' https://www.google-analytics.com https://js.stripe.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' https://api.example.com https://www.google-analytics.com;
frame-src https://js.stripe.com;
object-src 'none';
base-uri 'self';
form-action 'self';
upgrade-insecure-requests;
report-uri https://csp-report.example.com/collect;
" always;
Deployment approach โ always start in report-only mode:
# Phase 1: Report-only โ no blocking, collect violations
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report;" always;
# Phase 2: Once violations are clean, switch to enforcing
add_header Content-Security-Policy "default-src 'self'; ..." always;
CSP directives reference:
| Directive | Controls | Critical Setting |
|---|---|---|
default-src |
Fallback for all resource types | 'self' |
script-src |
JavaScript sources | Avoid 'unsafe-inline'; use nonces |
style-src |
CSS sources | Avoid 'unsafe-inline' where possible |
connect-src |
fetch, XHR, WebSocket targets | Prevents data exfiltration to unknown domains |
frame-ancestors |
Who can embed this page | 'none' or 'self' |
object-src |
Plugins (Flash etc.) | Always 'none' |
base-uri |
<base> tag targets |
'self' |
form-action |
Where forms can POST | 'self' โ critical for credential protection |
upgrade-insecure-requests |
Forces HTTPโHTTPS upgrades | Always include |
For SPAs and applications using inline scripts, use nonce-based CSP rather than 'unsafe-inline':
# Nginx with nonce โ requires server-side generation
# In application code, generate a cryptographically random nonce per request:
# nonce = base64(os.urandom(16))
# Inject into both the CSP header and the <script> tag
# Header:
add_header Content-Security-Policy "script-src 'self' 'nonce-$request_id';" always;
# Template:
# <script nonce="{{nonce}}">...</script>
For additional client-side protection depth, see the Advanced Web Application Firewall article.
Cross-Origin Headers
# Prevent cross-origin reads of your resources
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Isolate your browsing context โ required for SharedArrayBuffer, precise timers
add_header Cross-Origin-Opener-Policy "same-origin" always;
# Require CORP on all sub-resources (enables full cross-origin isolation)
add_header Cross-Origin-Embedder-Policy "require-corp" always;
Note:
Cross-Origin-Embedder-Policy: require-corpwill break loading of cross-origin resources (e.g. CDN assets, third-party iframes) that do not setCross-Origin-Resource-Policy. Only enable it if your application is designed for cross-origin isolation.
Complete Header Block โ Nginx
# Place in http {} or server {} block
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=()" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
4. Restrict HTTP Methods
Only enable the HTTP methods your application requires. TRACE enables cross-site tracing (XST) attacks and must always be disabled. OPTIONS reveals supported methods and is rarely needed externally.
# Nginx โ allowlist GET, POST, HEAD only
location / {
limit_except GET POST HEAD {
deny all;
}
}
# Apache โ block TRACE and OPTIONS globally
<LimitExcept GET POST HEAD>
Order deny,allow
Deny from all
</LimitExcept>
# Explicitly disable TRACE
TraceEnable Off
# Verify TRACE is disabled
curl -X TRACE https://example.com -I
# Should return: 405 Method Not Allowed
5. Directory Listing and File Exposure
Disable directory listing to prevent exposing file structures, backup files, and configuration to unauthenticated users.
# Nginx โ directory listing is off by default; confirm it
autoindex off;
# Apache โ disable directory listing
Options -Indexes -FollowSymLinks
# Also disable .htaccess override in production for performance + security
AllowOverride None
Block Sensitive File Access
Prevent direct access to configuration files, version control directories, logs, and backup files:
# Nginx
location ~* /\.(git|env|svn|htaccess|htpasswd|DS_Store) {
deny all;
return 404;
}
location ~* \.(bak|backup|sql|log|conf|config|ini|old|orig|save|swp|tmp)$ {
deny all;
return 404;
}
# Apache
<FilesMatch "(\.env|\.git|\.htaccess|\.htpasswd|\.log|\.bak|\.sql|\.conf|\.ini|\.swp|\.old)$">
Require all denied
</FilesMatch>
6. Request Size and Timeout Limits
Unconstrained request sizes enable denial-of-service attacks via large body uploads, and slow-read attacks via deliberately slow clients (Slowloris).
Nginx
http {
# Limit request body size (adjust for legitimate file upload needs)
client_max_body_size 10m;
# Limit header size
large_client_header_buffers 4 16k;
client_header_buffer_size 1k;
# Timeouts โ protect against Slowloris and slow-read attacks
client_body_timeout 12s; # Time to receive the request body
client_header_timeout 12s; # Time to receive the request headers
keepalive_timeout 15s; # Keep-alive connection timeout
send_timeout 10s; # Time between two successive writes to client
}
Apache
# mod_reqtimeout โ protect against Slowloris
<IfModule mod_reqtimeout.c>
RequestReadTimeout header=20-40,MinRate=500 body=20,MinRate=500
</IfModule>
# Limit request size
LimitRequestBody 10485760 # 10 MB
LimitRequestFields 100
LimitRequestFieldSize 8190
LimitRequestLine 8190
# Timeouts
Timeout 60
KeepAliveTimeout 5
MaxKeepAliveRequests 100
7. Rate Limiting
Rate limiting at the web server layer provides a fast, low-cost defence against brute force, credential stuffing, and scraping before requests reach the application.
http {
# Define rate limit zones
limit_req_zone $binary_remote_addr zone=global:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
server {
# Apply global rate limit with burst allowance
limit_req zone=global burst=50 nodelay;
# Stricter limit on authentication endpoints
location ~* ^/(login|signin|auth|token|password) {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
# ...
}
# API rate limit
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
}
}
}
# Apache โ mod_ratelimit (less granular than Nginx)
<Location "/login">
SetOutputFilter RATE_LIMIT
SetEnv rate-limit 400 # KB/s โ limits response rate, not request rate
</Location>
# For request-level rate limiting in Apache, use mod_evasive:
<IfModule mod_evasive24.c>
DOSHashTableSize 3097
DOSPageCount 5
DOSSiteCount 50
DOSPageInterval 1
DOSSiteInterval 1
DOSBlockingPeriod 10
</IfModule>
8. Error Handling
Custom error pages prevent leaking stack traces, server paths, framework versions, and SQL errors to clients.
# Nginx โ custom error pages
error_page 400 401 403 404 /errors/4xx.html;
error_page 500 502 503 504 /errors/5xx.html;
location ^~ /errors/ {
internal; # Only accessible via internal redirects, not directly
root /var/www;
}
# Apache
ErrorDocument 400 /errors/400.html
ErrorDocument 403 /errors/403.html
ErrorDocument 404 /errors/404.html
ErrorDocument 500 /errors/500.html
Ensure error pages:
- Do not include server paths, software versions, or stack traces
- Return the correct HTTP status code (not 200 with an error body)
- Do not include user-supplied input (reflected XSS risk)
9. Logging
Comprehensive, structured logging is essential for incident detection and forensic investigation.
Nginx โ Structured JSON Logging
http {
log_format json_combined escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$uri",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"http_referer":"$http_referer",'
'"http_user_agent":"$http_user_agent",'
'"http_x_forwarded_for":"$http_x_forwarded_for",'
'"ssl_protocol":"$ssl_protocol",'
'"ssl_cipher":"$ssl_cipher"'
'}';
access_log /var/log/nginx/access.log json_combined;
error_log /var/log/nginx/error.log warn;
}
Apache โ Combined Log Format
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %T" combined_plus
CustomLog /var/log/apache2/access.log combined_plus
ErrorLog /var/log/apache2/error.log
LogLevel warn
Log Security
- Do not log passwords or session tokens โ sanitise query strings before logging (
?token=REDACTED) - Rotate logs โ
logrotatedaily with compression and 90-day retention minimum - Forward to SIEM โ logs on the server are accessible to an attacker who has compromised it; ship off-host in real time
- Restrict log file permissions โ
640, owned by root, readable by log aggregation service user only
10. Keep-Alive and HTTP/2
# Enable HTTP/2 for performance and security (requires TLS)
listen 443 ssl http2;
# Keep-Alive โ balance resource use against performance
keepalive_timeout 65;
keepalive_requests 1000;
HTTP/2 provides several security benefits over HTTP/1.1:
- Header compression reduces attack surface for header-injection attacks
- Multiplexing reduces connection overhead, making Slowloris-style attacks less effective
- Requires TLS in practice (browsers only implement HTTP/2 over TLS)
11. Disable Unused Modules
Every enabled module is additional attack surface. Audit and disable modules not in use.
Apache โ Common Modules to Disable
# Disable modules (Debian/Ubuntu)
a2dismod status # Server status page โ disable on production
a2dismod info # Server info page โ disable on production
a2dismod userdir # User home directory access
a2dismod autoindex # Directory listing
a2dismod cgi # CGI execution โ disable unless needed
# Confirm loaded modules
apache2 -M
Nginx
Nginx is compiled with modules statically โ avoid enabling ngx_http_stub_status_module on production, or restrict it to localhost:
location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
12. Operating System and Process Hardening
The web server process itself should run with minimum privilege.
# Nginx / Apache should run as an unprivileged user, not root
# Nginx default: www-data or nginx
ps aux | grep nginx
# Ensure web root is not writable by the web server process
chown -R root:www-data /var/www/html
chmod -R 750 /var/www/html
# Web server should not have write access to its own config
chmod 644 /etc/nginx/nginx.conf
chown root:root /etc/nginx/nginx.conf
# Consider running in a chroot or container for additional isolation
Bind only to required network interfaces:
# Nginx โ if only serving on one interface
server {
listen 192.0.2.1:443 ssl http2; # Specific IP, not 0.0.0.0
}
Hardening Checklist
| Control | Priority | Verify With |
|---|---|---|
server_tokens off / ServerTokens Prod |
High | curl -I https://example.com \| grep Server |
X-Powered-By header removed |
Medium | curl -I https://example.com \| grep -i powered |
| TLS 1.0 and 1.1 disabled | Critical | testssl.sh or SSL Labs |
| TLS 1.3 enabled | High | testssl.sh |
| Weak cipher suites disabled | Critical | testssl.sh |
| OCSP stapling enabled | Medium | openssl s_client -status |
| HTTP โ HTTPS redirect (301) | Critical | curl -I http://example.com |
| HSTS header with โฅ1 year max-age | High | curl -I https://example.com \| grep -i strict |
| X-Content-Type-Options: nosniff | High | curl -I https://example.com |
| X-Frame-Options: DENY | High | curl -I https://example.com |
| Referrer-Policy set | Medium | curl -I https://example.com |
| Permissions-Policy set | Medium | curl -I https://example.com |
| CSP enforcing (not report-only) | High | curl -I https://example.com \| grep -i csp |
| TRACE method disabled | High | curl -X TRACE https://example.com -I |
| Directory listing disabled | High | Browse to a directory path |
.env, .git, backup files blocked |
Critical | curl https://example.com/.env โ 404 |
client_max_body_size / LimitRequestBody set |
Medium | Attempt large upload |
| Rate limiting on auth endpoints | High | ab or wrk against /login |
| Custom error pages (no stack traces) | Medium | Trigger a 404, 500 |
| Logs structured and forwarded to SIEM | High | Verify log aggregation |
| Unused modules disabled | Medium | nginx -V / apache2 -M |
| Web server runs as unprivileged user | High | ps aux \| grep nginx |
| Web root not writable by server process | High | ls -la /var/www/html |
| HTTP/2 enabled | Medium | curl --http2 -I https://example.com |