— Article — № 093

093 —Security

.htaccess header block: a security cheatsheet for handover

A copy-paste .htaccess block of security headers, file locks and PHP-in-uploads kills. Paste once, sleep better, hand the keys back without a knot in your stomach.

Overhead photo of printed .htaccess cheatsheet, manila folder tab, brass plate, index card, iron key, red wax seal on linen.
Hero · staged still№ 093

It's Friday afternoon. The migration ticket is closed, the client signed off on staging, and there is a draft email open titled Handover: credentials inside. Before that send button gets pressed, there is a block of .htaccess we always paste at the top of the production site's root config. It costs ninety seconds, it ships ten years of accumulated "oh no, that one" fixes, and it makes the next pentest report read about half as kind.

This post is that block. The whole thing, with the reasoning for every line, in the order we paste it. It targets Apache 2.4 on shared hosting, which is still where most legacy site installations of WordPress, Drupal and Magento actually live.

The block

Paste this at the top of the docroot .htaccess, above any existing WordPress, Drupal or Magento rewrite rules.

# === Handover hardening block, v6 (2026-06)
# Tested on Apache 2.4 + cPanel / Plesk / DirectAdmin

<IfModule mod_headers.c>
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always unset X-Powered-By
    Header always unset Server
</IfModule>

<IfModule mod_rewrite.c>
    RewriteEngine On
    # WordPress author enumeration
    RewriteCond %{QUERY_STRING} ^author=\d+ [NC]
    RewriteRule .* - [F,L]
    # xmlrpc brute force amplifier
    RewriteRule ^xmlrpc\.php$ - [F,L]
</IfModule>

# Lock dotfiles and stack config files
<FilesMatch "^(\.env|\.git.*|wp-config\.php|configuration\.php|settings\.php|local\.xml|app\.php)$">
    Require all denied
</FilesMatch>

# No PHP execution in user uploads
<Directory "/home/CLIENT/public_html/wp-content/uploads">
    <FilesMatch "\.(php|phar|phtml|pl|py|cgi)$">
        Require all denied
    </FilesMatch>
</Directory>

Options -Indexes
ServerSignature Off

That is it. Seven response headers, two rewrite rules, three lockdowns. The rest of the post is the reasoning, line by line, so you can defend it during code review and trim what does not apply.

Why each header earns its line

The five we set

X-Frame-Options: SAMEORIGIN. Yes, it is the older sibling of CSP's frame-ancestors directive, and yes, a proper CSP makes it redundant. On a legacy site we are typically a year away from shipping a real CSP, and there is no harm in shipping both. It blocks the cheapest clickjacking trick: loading the admin login in an invisible iframe.

X-Content-Type-Options: nosniff. Without it, older browsers second-guess MIME types. A .jpg in /uploads with PHP bytes inside can execute. We have seen this on a Magento 1 store with a contact-form upload that did not validate. The one-line fix is nosniff. MDN has the full reference.

Referrer-Policy: strict-origin-when-cross-origin. The same default Chrome ships. Stops your /wp-admin/ URLs leaking into third-party analytics when the editor clicks an outbound link in a draft post.

Permissions-Policy. Most legacy sites have no business asking for camera or geolocation. We close those gates by default. The interest-cohort=() token opts the site out of FLoC and its successors, which is mostly hygiene at this point.

Strict-Transport-Security. 31536000 is one year. We do not add preload on handover, because once a domain enters the browser preload list getting it off is a multi-month process and we cannot assume the client will keep certificates healthy forever.

The two we unset

Apache and PHP both volunteer their versions by default via Server and X-Powered-By. There is no good reason for the open internet to know your stack runs PHP 7.4.33 on Apache/2.4.41. mod_headers unset trims both. ServerSignature Off handles the rest, including the footer Apache adds to error pages.

The lockdown half: files, paths, PHP in uploads

Author enumeration. WordPress responds to /?author=1 with a 301 to /author/admin/ or whatever the slug is. That is a free username for anyone running wpscan. Block the query string and the redirect never fires.

xmlrpc.php. Unless the client specifically uses Jetpack or the WordPress mobile app, xmlrpc is a brute-force amplifier: one POST can try a thousand passwords via system.multicall. We deny it outright. If Jetpack screams later, whitelist Automattic's published IP ranges instead of reopening the door.

FilesMatch on config and dotfiles. Covers wp-config.php (WordPress), settings.php (Drupal), configuration.php (Joomla), local.xml (Magento 1), and any stray .env or .git the previous agency left behind. We have found a populated .env being served from more than one Laravel-leftover directory still living in production.

PHP in uploads. This is the one that has saved real sites. /wp-content/uploads should never execute PHP. Same for Drupal /sites/default/files and Magento /pub/media. The Directory block pins the rule to the upload directory; adjust the absolute path per site. The OWASP Secure Headers project has more if you want to go further.

Gotchas before you commit

A few more that bite us:

  • Always test on staging first. If mod_headers is not loaded, Apache silently drops everything inside the IfModule. The site keeps working; the headers do not. Curl with -I https://example.com and verify each line is actually present.
  • The Directory block needs an absolute server path, not a URL. CLIENT in the snippet is a placeholder. On cPanel hosts it is typically /home/USERNAME/public_html; on Plesk it is /var/www/vhosts/example.com/httpdocs.
  • If the site sits behind Cloudflare or another CDN, headers may also be set at the edge. Two copies of most headers is harmless. Two HSTS headers with conflicting max-ages cause undefined browser behaviour. Pick one origin and stick to it.
  • Nginx is a different language. Translate the directives into add_header and location blocks. The semantics are the same; the syntax is not. Apache's mod_headers documentation is the canonical reference.

What to do today

Open the .htaccess on the next legacy site you touch. Diff our block against what is already there. You will find half of these set in three different places by three different plugins, sometimes contradicting each other. Consolidate. Comment what stays. Comment why.

When we built Pier we ran into this exact thing on almost every site we docked with: nobody on the team wanted to be the one who shipped an HSTS header on the wrong host. The way we ended up handling it was to make every .htaccess edit a checkpoint in the version history, with the docked MySQL editor on hand to flip the WordPress siteurl back if a header change locked anyone out of the admin. Two clicks to revert, no SSH session.

The smallest thing you can do today: paste the block above into a comment at the top of your team's snippet repo, label it v1, and stop solving this problem from scratch on every handover.

— Questions —

Do I still need X-Frame-Options if I ship a Content-Security-Policy?

No, CSP's frame-ancestors supersedes it. But on a legacy site without a real CSP, X-Frame-Options is the single line that buys you clickjacking protection. Cheap to keep both.

Why not enable HSTS preload on handover?

Because once a domain is in the browser preload list, removing it takes months and a Chrome release. On a handover you cannot guarantee the client will keep every subdomain certificate renewed forever.

Will denying xmlrpc.php break Jetpack or the WordPress mobile app?

Yes. If the client uses either, whitelist Automattic's published IP ranges instead of removing the deny rule. Most agency sites have no live xmlrpc consumer and the deny is safe.

Does this work on Nginx?

No, .htaccess is Apache-only. Translate the directives into add_header lines and location blocks inside your server config. The semantics map cleanly; the syntax does not.