049 —Security
Eleven .htaccess directives worth keeping, three to drop
A cheatsheet for the .htaccess file you inherited on that legacy WordPress or Magento site: eleven directives worth keeping, and three that quietly hurt.
A Dutch agency we work with inherited a 2014 WordPress install last month. First thing the senior developer did was open .htaccess in vim, scroll past the WordPress rewrite block, and count 38 lines a previous freelancer had pasted in over the years. Two were doing real work. One was actively making the site slower. The rest was filler from old Stack Overflow answers.
If you maintain legacy sites, that file is probably your single highest-leverage security artifact. It runs on every request, it overrides server config without a restart, and it is almost always wrong by the time you find it. Below is the working set we keep on hand: eleven .htaccess directives that earn their keep on a typical WordPress, Joomla or custom-PHP install, plus three that look helpful and quietly hurt you.
Eleven directives worth keeping
These assume Apache 2.4 with mod_rewrite, mod_headers and mod_expires enabled. Drop them in the site-root .htaccess unless noted otherwise.
1. Force HTTPS
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
Place this above the WordPress block. The [L] flag stops further rewriting on the redirect itself, which avoids the loop you get when a downstream rule rewrites the redirected request.
2. HSTS
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
One year, subdomains included. Skip preload until you are certain every subdomain is HTTPS and will stay that way. See MDN on HSTS for the preload caveat.
3. Disable directory listing
Options -Indexes
If a folder has no index file and Indexes is on, Apache lists its contents. Forgotten /uploads/2019/ folders are how customer PDFs end up indexed by Google.
4. Protect dotfiles
<FilesMatch "^\.ht">
Require all denied
</FilesMatch>
Usually default in shipped Apache configs, but on shared hosting it is worth being explicit. Broaden the pattern to "^\." if you also want to block stray .env or .git files.
5. Lock the config file
<Files wp-config.php>
Require all denied
</Files>
Joomla equivalent is configuration.php; Drupal is sites/default/settings.php; Magento 1 is app/etc/local.xml. Block the one your stack actually uses.
6. nosniff
Header set X-Content-Type-Options "nosniff"
Stops browsers from second-guessing the Content-Type you sent, which is the mechanism behind a class of MIME-confusion attacks on user-uploaded files.
7. Frame deny
Header set X-Frame-Options "SAMEORIGIN"
Or set Content-Security-Policy: frame-ancestors 'self' if you already have a CSP. Pick one, not both, or browsers may disagree on precedence.
8. Referrer policy
Header set Referrer-Policy "strict-origin-when-cross-origin"
Cuts off the long tail of session tokens and admin URLs leaking out in the Referer header when an editor clicks an external link.
9. Block xmlrpc.php
<Files xmlrpc.php>
Require all denied
</Files>
Unless you still use the Jetpack mobile API or pingback, you do not need it. xmlrpc.php is the most brute-forced endpoint on the modern WordPress web.
10. No PHP execution in uploads
# wp-content/uploads/.htaccess (or sites/default/files/.htaccess on Drupal)
<FilesMatch "\.(php|phtml|phps|php\d+)$">
Require all denied
</FilesMatch>
This is the directive that stops a vulnerable upload form from becoming remote code execution. It belongs in the uploads folder, not the site root.
11. Cap request body
LimitRequestBody 10485760
10 MB. Pick a number that matches your real upload size and your PHP upload_max_filesize. Default is unlimited, which is how a single curl loop on a forgotten endpoint exhausts disk.
Three directives that quietly hurt you
These are the ones we delete on sight. Each looks defensive. Each is actively making things worse.
Wildcard CORS
Header set Access-Control-Allow-Origin "*"
Almost always pasted in to fix a font-loading error during development, then forgotten. On a site with cookie-authenticated admin endpoints, a wildcard origin combined with Access-Control-Allow-Credentials is a data-exfiltration class of bug. MDN's CORS doc spells out the precise interaction. If you genuinely need cross-origin font access, scope it: Header set Access-Control-Allow-Origin "https://cdn.example.com".
Year-long cache on HTML
ExpiresActive On
ExpiresDefault "access plus 1 year"
Copy-pasted from a "speed up your site" tutorial. Fine for fingerprinted assets like app.a1b2c3.css. Bad for HTML, which then sits in browser and CDN caches for a year regardless of what you publish. Always scope Expires by MIME type and keep HTML on a short policy:
ExpiresActive On
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType text/html "access plus 0 seconds"
Apache 2.2 access syntax on a 2.4 server
Order allow,deny
Deny from all
Allow from 81.23.45.67
On Apache 2.4 this only works if mod_access_compat is loaded. On a lot of modern hosts it is not, and the block silently does nothing while looking like an IP allowlist. The current syntax is Require ip 81.23.45.67 inside the relevant <Files> or <Directory> block. Apache's 2.4 upgrade notes have the full mapping.
Testing before you save
An .htaccess typo takes a site down on the next request. There is no compile step. Two habits that have saved us:
First, never edit the live file blindly. Pull it down, edit locally, and run apachectl -t against a matching local Apache. Failing that, httpd -t on the server itself before the change goes live.
Second, keep a dated backup before any edit: cp .htaccess .htaccess.2026-05-27. Most production breakages we have helped clients out of were a one-line revert away if a backup existed.
When we built Pier we ran into this exact thing on customer servers: the agency would not know whether an .htaccess edit had broken something until a client called. The way we ended up handling it was to keep a snapshot of every file Pier touches in version history, so a one-click revert is always there even if the FTP client is closed.
The smallest thing you could do today: open the .htaccess on your oldest active site, save a dated copy next to it, and delete the wildcard CORS line if it is in there. The other ten directives can wait until tomorrow.
— Questions —
Do I need both X-Frame-Options and CSP frame-ancestors?
Pick one. CSP frame-ancestors supersedes X-Frame-Options in modern browsers, but X-Frame-Options is fine on its own if you do not have a CSP yet.
Will blocking xmlrpc.php break Jetpack?
It will break the Jetpack mobile app and pingback. If neither is in use on the site, the block is safe and removes the most brute-forced endpoint.
Can I disable .htaccess entirely and use the main config?
Yes, if you control the server. On shared hosting you usually cannot. .htaccess is often the only override mechanism available there.
Does LimitRequestBody affect WordPress media uploads?
Yes. Set it just above your real largest upload, and match the PHP upload_max_filesize and post_max_size values in php.ini.