Apache Hardening: The Guide to Amplify Your Security (with everything that can go wrong if you don't) Every default Apache configuration you ignore is an open door. This guide shows you exactly what each one means in practice — and how to close them, one by one.
Every default Apache configuration you ignore is an open door. This guide shows you exactly what each one means in practice — and how to close them, one by one.
Apache HTTP Server is one of the most widely used web servers on the planet for over 25 years. And precisely because of that, it's also one of the most studied targets by attackers. Its default configuration is designed to work anywhere, not to be secure anywhere. This difference is what separates a healthy server from one that becomes a headline.
In this guide, we'll go through each hardening item explaining three things:
By the end, you'll have Apache ready for production and will understand exactly why each line is there.
When Apache responds to any request, it includes a Server header that by default contains something like:
Server: Apache/2.4.52 (Ubuntu)
And on error pages (404, 500, 403), it adds a footer:
Apache/2.4.52 (Ubuntu) Server at example.com Port 443
It may seem harmless. It's not.
Imagine you're running Apache 2.4.52 on Ubuntu. An attacker scanning the internet with Shodan, Censys, or a simple Python script discovers your server. In seconds:
/var/www/html), where logs are, who runs the service (www-data).The cost of an attacker discovering all this went from "hours of reconnaissance" to "one HTTP request". You saved them work.
Worse: automated scanners like Mirai and its derivatives scan the internet 24/7 looking for exactly vulnerable specific versions. It's not personal — it's industrial.
In /etc/apache2/conf-enabled/security.conf (Debian/Ubuntu) or /etc/httpd/conf/httpd.conf (RHEL/CentOS/Rocky):
# Shows only "Apache" in Server header, no version or OS
ServerTokens Prod
# Removes the footer from error pages
ServerSignature Off
After that, the Server header becomes simply Apache. The attacker still knows it's Apache (there's no way to hide it 100%), but lost the version and operating system. They can't filter specific exploits without extra work anymore.
Important: this is "security through obscurity" and doesn't replace keeping the server updated. But drastically reduces the number of automated attacks that can find you as a viable target.
Every response generated by a PHP page brings a header like:
X-Powered-By: PHP/8.1.2
Same problem as ServerTokens, with an aggravating factor: PHP vulnerabilities tend to be much more critical than Apache vulnerabilities, because PHP executes code. Some of the most well-known CVEs from recent years (CVE-2019-11043 in PHP-FPM, for example) allowed remote code execution with a single well-formed request.
If the attacker knows you're running PHP 8.1.2, they consult that exact version's CVEs and try. If your version is vulnerable and hasn't been patched yet, it's game over.
There's another less obvious side effect: bug bounty tools and aggressive scanners prioritize targets with old versions. Exposing your version is like hanging a sign that says "shoot here first".
In php.ini (use php --ini to discover the exact path):
expose_php = Off
Restart Apache (or PHP-FPM, depending on your setup). The header disappears completely.
Attention: if you use PHP-FPM, there are two different php.ini files — one for CLI and one for FPM. Adjust the FPM one (/etc/php/8.x/fpm/php.ini).
The TRACE HTTP method was created in the 90s for debugging purposes — when you send a TRACE, the server responds with the entire request it received, intact. By default, Apache comes with TraceEnable On.
There's a known attack called Cross-Site Tracing (XST). The idea: using JavaScript on a compromised page, an attacker forces the victim's browser to do a TRACE on the target server. The response includes all headers — including cookies marked as HttpOnly, which normally JavaScript can't read.
Result: the attacker captures session cookies that should be inaccessible and hijacks the victim's session. It works to this day on misconfigured servers.
Plus, automated vulnerability scanners mark TRACE enabled as medium severity vulnerability, which affects compliance scores (PCI-DSS, for example, requires TRACE disabled).
TraceEnable Off
Done. No side effects, no impact on real applications. I don't know a single legitimate site that needs TRACE enabled in production.
The ETag header is used for caching: the browser saves an ETag of a file and, on the next visit, asks the server "does this file still have that ETag?". If yes, the server responds 304 Not Modified and the browser uses the local version. Efficient.
The problem: by default, Apache generates the ETag from the file's inode, size, and modification date. The inode is an internal filesystem number.
In itself, leaking an inode seems trivial. But:
It's not the end of the world, but it's trivial to fix.
FileETag None
You'll still have caching working via Last-Modified and Cache-Control headers — which are sufficient for any real case.
Apache doesn't send HSTS by default. This means even if your site runs on HTTPS, a browser accessing for the first time still speaks HTTP on the first request — and only then gets redirected.
There's a class of attacks called SSL Stripping, popularized by the sslstrip tool. The typical scenario:
With HSTS active, the browser refuses to speak HTTP with that domain. Even if the attacker intercepts, the browser shows an insuperable error and blocks the connection.
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
What each part means:
max-age=63072000 — the browser should force HTTPS for 2 years from the last visitincludeSubDomains — also applies to all subdomains (careful! see warning below)preload — requests inclusion in the hardcoded list of browsers (Chrome, Firefox, Safari)CRITICAL WARNING: once applied, the browser "remembers" the HSTS by the max-age defined. If your certificate expires, or if you need to access the site via HTTP for any reason, there's no going back — the user stays locked out. Start with max-age=300 (5 minutes) during testing, validate everything, and only then increase to production value.
About includeSubDomains: if you have legacy.example.com that still only speaks HTTP, this directive will break access to it. Check all subdomains before applying.
Without this header, any site on the internet can load yours inside an <iframe>. Yes, any one.
The attack is called clickjacking and works like this:
opacity: 0Real cases have happened with Twitter, Facebook, internet banking. It's an especially dangerous vector for admin panels.
Header always set X-Frame-Options "SAMEORIGIN"
This allows only your own domain to put the site in an iframe. To block completely, use DENY. To allow a specific domain, use the more modern Content-Security-Policy: frame-ancestors directive (which we'll see in item 9).
When a browser receives a file and the Content-Type seems strange or missing, it tries to guess the file type by looking at the content. This behavior is called "MIME sniffing".
Classic scenario: your site allows uploading avatar images. You validate that the file is a PNG by checking the extension. The attacker sends a file called photo.png whose content is actually JavaScript:
<script>stealCookies()</script>
You save the file, it sits in /uploads/photo.png. When another victim accesses that URL, the server responds with Content-Type: image/png. But the browser looks at the content, sees <script>, and thinks: "Oh, that looks like HTML!" — and executes it as HTML. The JavaScript runs in your domain's context. Stored XSS in a few steps.
This was a devastating attack in Internet Explorer of the 2000s, but still works in modern browsers under certain conditions.
Header always set X-Content-Type-Options "nosniff"
This header forces browsers to respect the Content-Type sent, without trying to guess. It's one line. Enable it always.
When a user clicks a link on your site to an external site, the browser sends a Referer header (yes, with a spelling error — it's historical) telling the destination exactly which URL they came from. By default, this includes the full URL.
Imagine your system has URLs like:
https://mysystem.com/admin/users?token=eyJhbG...
Or:
https://mysystem.com/reset-password?key=abc123def456
Or even:
https://myhospital.com/patient/john-silva-ssn-12345/exams
If the user clicks an external link on that page (or if the page loads an image from another domain), that entire URL is sent to the external domain, which will see it in its logs. Tokens, IDs, personal data — everything leaking.
This has already caused famous leaks — the most well-known case is U.S. healthcare systems that leaked patient identifiers to Facebook because of tracking pixels.
Header always set Referrer-Policy "strict-origin-when-cross-origin"
What this policy does:
https://mysystem.com), no path or query stringIt's a very good balance between privacy and functionality.
Without CSP, the browser executes any JavaScript that appears on the page, from any origin. Loads images from anywhere. Accepts styles from any source. Connects to any server via fetch().
CSP is the most powerful modern defense against Cross-Site Scripting (XSS) — and XSS is, according to OWASP, one of the most common web vulnerabilities for over 20 years.
Scenario without CSP:
<script>)<script src="https://attacker.com/malware.js"></script>With well-configured CSP, the browser simply refuses to load the script from attacker.com, even if it's injected in the HTML. The XSS vulnerability still exists in the code, but the impact is zero.
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
Deciphering:
default-src 'self' — by default, only load things from your own domainscript-src 'self' — JavaScript only from your own domain (no inline, no external CDN)style-src 'self' 'unsafe-inline' — CSS from your own domain + allows inline styles (necessary for many frameworks)img-src 'self' data: https: — images from your own domain, data URIs, and any HTTPSconnect-src 'self' — fetch/XHR only to your own domainframe-ancestors 'self' — replaces the modern X-Frame-Optionsbase-uri 'self' — prevents <base> from being injected to change the base pathform-action 'self' — forms can only submit to your own domainCSP is the hardest header to configure. The policy above will break sites using Google Analytics, Google Fonts, jQuery via CDN, embedded maps, etc. Always test first with Content-Security-Policy-Report-Only, which only reports violations without blocking. Use this for a few days, monitor the browser logs, adjust, and only then switch to real Content-Security-Policy.
Without this header, any page on your site can request access to camera, microphone, geolocation, USB, motion sensors, etc. Even if your page never uses these, if there's an XSS, the attacker can inject code that requests these accesses.
Imagine an XSS on your corporate site. An attacker injects:
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => /* sends stream to attacker's server */)
The victim sees the "allow camera?" popup — and since it's on a site they trust, clicks allow. Done: the executive's webcam and microphone, streaming live to the attacker.
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
This completely blocks these APIs. If your application really needs one (a specific page that uses camera), you enable it just for that origin using camera=(self).
Without these headers, your window shares a process with other windows that may have been opened from your site, and your resources can be loaded by any origin.
After the Spectre and Meltdown attacks (2018), it was discovered that via JavaScript, it was possible to read memory from other browser processes through CPU side channels. The defense: isolate processes by origin.
same-origin, the browser guarantees your window is in a process isolated from other-origin windowsHeader always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Resource-Policy "same-origin"
# COEP only if you don't embed third-party resources
Header always set Cross-Origin-Embedder-Policy "require-corp"
Warning about COEP: the require-corp breaks any third-party resource that doesn't send the Cross-Origin-Resource-Policy header. If your site embeds YouTube, Google maps, CDN fonts, etc., remove COEP or change to unsafe-none.
Apache accepts practically all HTTP methods by default, including PUT, DELETE, OPTIONS, CONNECT, PATCH. If there's any misconfigured module (or a forgotten old WebDAV), methods like PUT can allow arbitrary file uploads.
There are documented cases of Apache servers with WebDAV accidentally enabled where attackers did PUT /shell.php and installed a webshell in seconds. Total game over — remote code execution with a single request.
Even without WebDAV, enabled dangerous methods appear in vulnerability scanners and impact compliance.
Restrict methods in the root <Directory>:
<Directory /var/www/html>
<LimitExcept GET POST HEAD>
Require all denied
</LimitExcept>
</Directory>
If your application is a REST API that needs PUT, DELETE, and PATCH, adjust:
<LimitExcept GET POST HEAD PUT DELETE PATCH OPTIONS>
Require all denied
</LimitExcept>
In many setups, if you access a folder URL (/uploads/) and there's no index.html inside, Apache lists all files in the folder. Pretty, organized, with date and size.
Real scenarios:
/backups/ — listing all database dumps/uploads/ — all documents uploaded by users, including internal PDFs/.git/ — entire source code history/old/ — old site versions with vulnerabilities already fixed in the new versionI've seen this leak confidential contracts, database dumps with password hashes, complete source code of commercial applications, and (in a particularly unpleasant case) personal photos of employees who had uploaded the wrong folder's contents.
In the <Directory> or in a .htaccess:
Options -Indexes
And to ensure defense in depth, block direct access to sensitive files:
<FilesMatch "(^\.|\.(bak|backup|swp|old|sql|sql\.gz|tar|tar\.gz|zip|log|env|ini|conf|config|yml|yaml|json|lock)$|composer\.(json|lock)|package(-lock)?\.json|\.git|\.svn|\.htaccess|\.htpasswd|wp-config\.php)">
Require all denied
</FilesMatch>
And prevent access to any file/folder starting with .:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule "(^|/)\." - [F]
</IfModule>
This protects .git, .env, .svn, .htaccess, .DS_Store, etc.
By default, Apache executes PHP in any folder within the DocumentRoot. Including the upload folder.
Combine this with any upload failure — weak extension filtering, MIME type validation only, anything — and you have webshell upload. The attacker sends shell.php, accesses /uploads/shell.php, and has command execution access on the server with the Apache user.
This is, for over 15 years, one of the most common compromise patterns in WordPress, Joomla, and any PHP application with upload management.
<Directory /var/www/html/uploads>
php_flag engine off
AddType text/plain .php .phtml .php3 .php4 .php5 .pht .phar
<FilesMatch "\.(php|phtml|php3|php4|php5|pht|phar)$">
Require all denied
</FilesMatch>
</Directory>
Three layers of protection: disable the PHP engine, force PHP extensions to be served as text, and block direct access to files with those extensions. Defense in depth.
In many distributions, Apache comes with support for SSLv3, TLS 1.0, and TLS 1.1 enabled by default. These protocols have known vulnerabilities (POODLE, BEAST, FREAK) and were officially discontinued by the IETF in 2021.
Even if your modern browser negotiates TLS 1.3, the fact that the server accepts TLS 1.0 means malicious clients can force downgrade. Besides, any serious certification (PCI-DSS, ISO 27001, LGPD in some interpretations) requires TLS 1.2 minimum.
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder off
SSLSessionTickets off
# OCSP Stapling — improves performance and privacy of certificate validation
SSLUseStapling on
SSLStaplingCache "shmcb:logs/ssl_stapling(32768)"
To generate a custom configuration (including "modern", "intermediate", and "old" profiles for different compatibility levels), use the Mozilla SSL Configuration Generator.
Apache accepts requests of arbitrary size. No limit.
An attacker can send a 4GB POST to any endpoint on your site and exhaust the server's memory. Repeating this from several origins, they bring down the server without needing a botnet. It's amateur DoS, but it works.
# Limits uploads to 10MB per request (adjust as needed)
LimitRequestBody 10485760
For specific endpoints that need to accept larger uploads (for example, video upload), define the limit locally.
Here's the complete file ready to copy. Save to /etc/apache2/conf-available/security-hardening.conf and enable with:
sudo a2enconf security-hardening
sudo apachectl configtest
sudo systemctl restart apache2
# ============================================================
# SECURITY HARDENING - Apache HTTP Server
# Place in /etc/apache2/conf-available/security-hardening.conf
# Enable with: sudo a2enconf security-hardening
# ============================================================
# --- Hide versions and server information ---
ServerTokens Prod
ServerSignature Off
TraceEnable Off
FileETag None
# --- HTTP security headers ---
<IfModule mod_headers.c>
# Forces HTTPS for 2 years (CAREFUL: start with max-age=300 to test!)
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
# Anti-clickjacking
Header always set X-Frame-Options "SAMEORIGIN"
# Anti MIME-sniffing
Header always set X-Content-Type-Options "nosniff"
# Controls URL leakage via Referer
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Blocks sensitive browser APIs
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
# Base CSP (TEST first with Content-Security-Policy-Report-Only!)
Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
# Cross-origin isolation
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Resource-Policy "same-origin"
# COEP only if you DON'T embed third-party resources:
# Header always set Cross-Origin-Embedder-Policy "require-corp"
# Remove headers that leak information
Header always unset X-Powered-By
Header always unset Server
Header unset X-Powered-By
Header unset Server
</IfModule>
# --- Block dangerous HTTP methods ---
<Directory /var/www/html>
Options -Indexes -Includes -ExecCGI
AllowOverride None
Require all granted
<LimitExcept GET POST HEAD>
Require all denied
</LimitExcept>
</Directory>
# --- Block sensitive files ---
<FilesMatch "(^\.|\.(bak|backup|swp|old|sql|sql\.gz|tar|tar\.gz|zip|log|env|ini|conf|config|yml|yaml|json|lock)$|composer\.(json|lock)|package(-lock)?\.json|\.git|\.svn|\.htaccess|\.htpasswd|wp-config\.php)">
Require all denied
</FilesMatch>
# --- Block folders/files starting with . ---
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule "(^|/)\." - [F]
</IfModule>
# --- Upload folder without PHP execution ---
<Directory /var/www/html/uploads>
php_flag engine off
AddType text/plain .php .phtml .php3 .php4 .php5 .pht .phar
<FilesMatch "\.(php|phtml|php3|php4|php5|pht|phar)$">
Require all denied
</FilesMatch>
</Directory>
# --- Request size limit ---
LimitRequestBody 10485760
# --- Strong SSL/TLS ---
<IfModule mod_ssl.c>
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder off
SSLSessionTickets off
SSLUseStapling on
SSLStaplingCache "shmcb:logs/ssl_stapling(32768)"
</IfModule>
And the complementary .htaccess (for shared hosting or environments where you don't have access to the main conf):
# .htaccess - place in site root
<IfModule mod_rewrite.c>
RewriteEngine On
# Force HTTPS
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Block access to files/folders starting with dot
RewriteRule "(^|/)\." - [F]
# Block user agents from common malicious scanners
RewriteCond %{HTTP_USER_AGENT} (nikto|sqlmap|fimap|nessus|whatweb|jbrofuzz|libwhisker|webshag|grabber|dirbuster) [NC]
RewriteRule .* - [F,L]
</IfModule>
# Block directory listing
Options -Indexes
# Block sensitive files
<FilesMatch "(^\.|\.(bak|backup|swp|old|sql|sql\.gz|tar|tar\.gz|zip|log|env|ini|conf|config|yml|yaml|json|lock)$|composer\.(json|lock)|package(-lock)?\.json|\.git|\.svn|\.htaccess|\.htpasswd|wp-config\.php)">
Require all denied
</FilesMatch>
# Upload limit (10MB)
LimitRequestBody 10485760
After applying everything:
# Validate Apache syntax
sudo apachectl configtest
# Restart
sudo systemctl restart apache2
# Test headers
curl -I https://yourdomain.com
Also use external tools:
ServerTokens Prod and ServerSignature OffTraceEnable OffFileETag Noneexpose_php = Off in php.iniheaders, rewrite, ssl enabledOptions -Indexes appliedFilesMatchHardening is not an event, it's a continuous process. Did you apply all this today? Great. But tomorrow someone is going to install a new WordPress plugin, someone is going to update PHP and reset configurations, someone is going to put a backup file in a public folder "just for a moment", someone is going to enable TRACE for debugging and forget.
The only realistic way to keep a server secure long-term is to monitor continuously — some tool has to be watching, every day, to see if anything changed for the worse.
That's exactly what SentinelHub is for: continuous web security scanning, with real-time alerts when something changes, descriptions in English, reports ready to show the boss, and detection of everything this guide covered (and much more).
Next post in the series: Nginx Hardening — same principles, different configuration, some exclusive tricks. Don't miss it.
Liked it? Share with your company's sysadmin. Think they should have read this yesterday? They probably should.