Harden WordPress Security Using Custom .htaccess Files
When running a WordPress website or any Apache-powered website, security should never be an afterthought. By default, WordPress ships with a minimal .htaccess file that only manages permalinks. This leaves a website potentially exposed to spam, bots, brute-force attacks, and performance issues.
A custom .htaccess file provides a first line of defense at the server level. These rules can block some unwanted requests before they reach PHP or the application layer. While this article focuses on WordPress, most examples apply to any Apache-powered website, including PHP applications, static sites, and APIs. Websites running on Nginx do not use .htaccess files. The equivalent rules on Nginx are configured directly in the server block.
Below, a hardened .htaccess file is examined section by section with an explanation for what each rule does.
Why Use a Custom .htaccess File
The default WordPress .htaccess file only handles permalinks. It does not:
- Protect sensitive files.
- Filter malicious bots or spam.
- Optimize caching or compression.
- Add essential security headers.
These protections can reduce exposure to certain unwanted requests and provide additional defense at the Apache layer. A custom .htaccess file improves both security and performance by blocking attacks early and enabling server-level optimizations.
Where to Find the .htaccess File
The .htaccess file is usually located in the root directory of the website. This is the same folder that contains the main index.php file.
For a typical WordPress installation, this means:
- If WordPress is installed in the root of a domain,
.htaccessis found in thepublic_html(orwww) folder. - If WordPress is installed in a subdirectory (for example,
example.com/blog), then the.htaccessfile will be inside that subdirectory.
The file name begins with a dot, which makes it hidden on many systems. In FTP clients or file managers, it may be necessary to enable “show hidden files” to see it. If an .htaccess file does not exist, WordPress can generate one automatically. Go to Settings > Permalinks in the WordPress dashboard and click Save Changes. This creates a default file if one does not already exist.
Non-WordPress Apache websites also place .htaccess files in the directory where the rules should apply. Multiple .htaccess files can exist in different folders, each controlling access or behavior for its directory and subdirectories.
Hardened .htaccess File
The following is an example .htaccess file that strengthens security and performance using multiple methods.
NOTE: The domain name is assumed to be
www.example.comand the IPs and TLDs shown are placeholders. Values should be adjusted for the actual environment. Most of these directives, including<RequireAll>,<FilesMatch>, andOptions, require the Apache virtual host to haveAllowOverride All(or at minimumAllowOverride Options Limit AuthConfig) set. On shared hosting this is usually already configured, but on a self-managed VPS or Docker setup it may need to be enabled in the Apache configuration before any of these rules take effect.
# =========================
# Security & Server Options
# =========================
Options -Indexes +FollowSymLinks -MultiViews
ServerSignature Off
# =========================
# Restrict HTTP Methods
# =========================
<LimitExcept GET POST HEAD OPTIONS>
Require all denied
</LimitExcept>
# =========================
# Block specific IPs globally
# =========================
<RequireAll>
Require all granted
Require not ip 10.0.0.1
Require not ip fc00::/7
</RequireAll>
# =========================
# Protect Sensitive Files
# =========================
<FilesMatch "wp-config\.php|error_log|readme\.html|license\.txt|wp-config-sample\.php|\.htaccess|\.env">
Require all denied
</FilesMatch>
# =========================
# Lock wp-login and xmlrpc to specific IPs
# =========================
<FilesMatch "^(wp-login\.php|xmlrpc\.php)$">
<RequireAny>
Require ip 172.16.0.1
Require ip 172.30.254.1
</RequireAny>
</FilesMatch>
# =========================
# Rewrite Rules
# =========================
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# =========================
# Block HTTP/1.0 requests
# =========================
RewriteCond %{SERVER_PROTOCOL} ^HTTP/1\.0$
RewriteRule ^ - [F,L]
# =========================
# Block empty / nonsense user agents
# =========================
RewriteCond %{HTTP_USER_AGENT} ^(?:\s|-)*$
RewriteRule ^ - [F,L]
# =========================
# Block common bad bots / scrapers
# =========================
RewriteCond %{HTTP_USER_AGENT} (?:^|[^A-Za-z])(?:curl|wget|python-requests|nikto|sqlmap) [NC]
RewriteRule ^ - [F,L]
# =========================
# Block spammy referers
# =========================
RewriteCond %{HTTP_REFERER} ^https?://(?:[^.]+\.)*[^.]+\.(?:test|invalid)(?:/.*)?$ [NC,OR]
RewriteCond %{HTTP_REFERER} ^https?://(?:[^.]+\.)*(?:example\.net|example\.org)(?:/.*)?$ [NC]
RewriteRule ^ - [F,L]
# =========================
# Block hotlinking (images, archives, PDFs)
# =========================
RewriteCond %{HTTP_REFERER} !^$
RewriteCond %{HTTP_REFERER} !^https?://(?:[^.]+\.)*example\.com(?:/.*)?$ [NC]
RewriteRule \.(?:jpe?g|gif|png|svg|webp|zip|rar|pdf)$ - [F,L]
# =========================
# Block author scans (?author=1)
# =========================
RewriteCond %{QUERY_STRING} (author=\d+) [NC]
RewriteRule ^ - [F,L]
# =========================
# Harden WordPress core paths
# =========================
RewriteRule ^wp-admin/includes/ - [F,L]
RewriteRule !^wp-includes/ - [S=3]
RewriteRule ^wp-includes/[^/]+\.php$ - [F,L]
RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [F,L]
RewriteRule ^wp-includes/theme-compat/ - [F,L]
# =========================
# Block old permalinks (410 Gone)
# =========================
RewriteCond %{REQUEST_URI} ^/(post-permalink|2021/02/28/another-post-permalink) [NC]
RewriteRule ^ - [G,L]
# =========================
# Comment spam filter
# =========================
RewriteCond %{REQUEST_METHOD} POST
RewriteCond %{REQUEST_URI} wp-comments-post\.php [NC]
RewriteCond %{HTTP_REFERER} !^https?://(?:[^.]+\.)*example\.com(?:/.*)?$ [NC]
RewriteRule ^ - [F,L]
</IfModule>
# =========================
# Redirects
# =========================
RedirectMatch 301 ^/post-permalink(?:/.*)?$ https://www.example.com/updated-post-permalink/
RedirectMatch 301 ^/another-post-permalink(?:/.*)?$ https://www.example.com/replacement-permalink/
RedirectMatch 301 ^/([0-9]{4})/([0-9]{2})/([0-9]{2})/(.+)$ https://www.example.com/$4
# =========================
# Security Headers
# =========================
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "same-origin"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
</IfModule>
# =========================
# Compression
# =========================
<IfModule mod_deflate.c>
SetOutputFilter DEFLATE
<IfModule mod_setenvif.c>
SetEnvIfNoCase Request_URI \.(?:jpe?g|gif|png|svg|webp|zip|rar|pdf)$ no-gzip dont-vary
</IfModule>
</IfModule>
# =========================
# Caching
# =========================
<IfModule mod_expires.c>
ExpiresActive On
ExpiresDefault "access plus 1 month"
# =========================
# No caching for HTML & API responses
# =========================
ExpiresByType text/html "access plus 0 seconds"
ExpiresByType application/json "access plus 0 seconds"
ExpiresByType application/xml "access plus 0 seconds"
ExpiresByType text/xml "access plus 0 seconds"
# =========================
# Short cache for feeds
# =========================
ExpiresByType application/rss+xml "access plus 1 hour"
ExpiresByType application/atom+xml "access plus 1 hour"
# =========================
# Long cache for static assets
# =========================
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/avif "access plus 1 year"
ExpiresByType image/ico "access plus 1 year"
ExpiresByType image/x-icon "access plus 1 year"
ExpiresByType video/mp4 "access plus 1 year"
ExpiresByType video/webm "access plus 1 year"
ExpiresByType audio/mpeg "access plus 1 year"
ExpiresByType audio/ogg "access plus 1 year"
ExpiresByType font/woff "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType font/ttf "access plus 1 year"
ExpiresByType font/otf "access plus 1 year"
ExpiresByType application/vnd.ms-fontobject "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType application/pdf "access plus 1 month"
ExpiresByType application/zip "access plus 1 month"
<IfModule mod_headers.c>
Header append Cache-Control "public"
</IfModule>
</IfModule>
# =========================
# WordPress Core
# =========================
# BEGIN WordPress
# The directives (lines) between "BEGIN WordPress" and "END WordPress" are
# dynamically generated, and should only be modified via WordPress filters.
# Any changes to the directives between these markers will be overwritten.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
How the Hardened .htaccess Rules Work
Security & Server Options
Options -Indexes: Prevents directory listings so visitors cannot see the website’s folder contents.+FollowSymLinks: Required for rewrite rules to function.-MultiViews: Disables content negotiation that could accidentally expose files.ServerSignature Off: Hides Apache version details to reduce fingerprinting.
Restricting HTTP Methods
Only the HTTP methods required by the website should be allowed. This example permits GET, POST, HEAD, and OPTIONS, which is appropriate for many conventional WordPress websites but can break APIs or other applications that legitimately use methods such as PUT, PATCH, or DELETE.
Blocking Specific IPs
Certain IPs can be denied while all others remain allowed. This is useful for blocking repeat offenders, though attackers can rotate IPs, so it should be part of a layered strategy.
These IP-based rules check the connecting IP directly and assume Apache sees the real client address. Behind a CDN, reverse proxy, or load balancer (Cloudflare being a common case), that address is usually the proxy’s IP rather than the visitor’s. Cloudflare’s current recommendation is mod_remoteip, configured with RemoteIPHeader CF-Connecting-IP and RemoteIPTrustedProxy entries for Cloudflare’s published IP ranges. Other CDNs and reverse proxies typically require an equivalent mod_remoteip configuration pointed at their own header and trusted IP ranges.
Protecting Sensitive Files
Critical files like wp-config.php, .env, server logs, and .htaccess are blocked from public access. This prevents accidental exposure of credentials or server details.
Locking wp-login and xmlrpc to Specific IPs
Locks wp-login.php and xmlrpc.php to specific IPs for stronger access control. The IP restriction should be removed entirely if those files need to be accessible from arbitrary or unpredictable locations, since a static IP allowlist cannot accommodate that. Restricting xmlrpc.php can also break services that depend on XML-RPC.
Rewrite Rules (Security Filters)
This section applies multiple protections:
- Blocks old
HTTP/1.0requests, which attackers sometimes use for bypass tricks. - Rejects empty, whitespace-only, or placeholder user agents.
- Filters out common scraping tools, bots, and vulnerability scanners.
- Stops spammy or malicious referrers.
- Prevents hotlinking of images, PDFs, and archives, saving bandwidth.
- Blocks author enumeration attempts (
?author=1) that reveal WordPress usernames. - Restricts access to critical WordPress core paths.
- Returns
410 Gonefor outdated permalinks to keep SEO clean. - Filters comment spam by requiring that posts originate from the domain.
Blocking HTTP/1.0 requests can occasionally affect legitimate older clients or monitoring tools, so it is worth confirming that no legitimate traffic still relies on HTTP/1.0 before enabling this rule.
User-Agent filtering is easily bypassed because clients can send an arbitrary User-Agent. This should be considered a basic nuisance filter rather than a security boundary. Additionally, curl, wget, or other general-purpose clients may be used by legitimate scripts, monitoring systems, and administrators.
The comment rule filters some automated comment submissions by checking that the request includes a same-site Referer header. Because the Referer header can be absent for legitimate requests, this should be treated as a heuristic rather than a complete spam solution.
Redirect Rules
Outdated URLs are permanently redirected to new ones. This preserves SEO value, prevents broken links, and ensures visitors land on the right content.
Security Headers
Modern headers defend against common web threats:
X-Frame-Options: Prevents clickjacking.X-Content-Type-Options: Stops MIME sniffing.Referrer-Policy: Reduces referrer data leaks.Strict-Transport-Security(HSTS): Enforces HTTPS and prevents downgrade attacks.Permissions-Policy: Blocks access to sensitive browser features (camera, microphone, geolocation).
Because HSTS is persistent and includeSubDomains applies the policy to subdomains, verifying that every relevant host supports HTTPS is recommended before enabling these directives.
The preload directive should only be added if the domain meets the requirements for HSTS preloading and is intended for inclusion in browser preload lists.
Compression
Enables server-side DEFLATE compression for text-based files (HTML, CSS, JS) while excluding already-compressed assets like images, PDFs, and archives. This reduces bandwidth and speeds up page delivery.
Caching
Sets smart caching rules:
- Minimal caching for HTML and API responses (ensures users always see fresh content).
- Short caching (1 hour) for RSS/Atom feeds.
- Long-term caching (1 year) for static assets like images, fonts, videos, CSS, and JavaScript.
This balance reduces server load, speeds up repeat visits, and keeps dynamic content accurate.
HTML and API responses are configured with a cache lifetime of zero seconds. This causes them to become immediately stale, requiring caches to revalidate the response rather than serving it as a fresh cached response.
Websites already using a caching plugin should compare these mod_expires rules against the plugin’s own headers. Most plugins either respect existing Expires or Cache-Control headers or override them, but conflicting rules for the same file type can produce unpredictable results.
The MIME types listed above cover common file formats but may not match every format used by the website. Entries should be added or removed based on the actual file types the website serves.
NOTE:
mod_expiressupports wildcard MIME types as of Apache 2.4, so a rule such asExpiresByType image/* "access plus 1 year"can be used to apply the same expiration policy to all image MIME types.
WordPress Block
Everything outside the BEGIN WordPress and END WordPress markers is untouched by WordPress core updates or the Permalinks settings screen, so the custom rules persist across updates. Only the block between those markers is subject to being regenerated automatically, which typically happens when the permalink structure is changed and saved from the WordPress dashboard.
Testing and Troubleshooting
Before replacing a live .htaccess file, a few precautions reduce the risk of downtime:
- Back up the existing
.htaccessfile first. If anything goes wrong, restoring the original file immediately returns the website to its prior state. - If shell access to the server is available, validate the file’s syntax before deploying. Running
apachectl configtest(orapache2ctl configteston Debian-based systems) checks the configuration for errors without requiring a live request to trigger them. - Test on a staging environment or a local copy of the website before applying changes to production, particularly the IP-restriction and HTTP method rules, which can lock out legitimate access if misconfigured.
- Add the rules incrementally rather than all at once, when possible. Applying one section at a time (server options, then rewrite rules, then headers) makes it easier to identify which rule caused an issue.
Fixing a 500 Internal Server Error
A 500 Internal Server Error immediately after updating .htaccess usually indicates one of two issues:
- A syntax error in the file. Apache is strict about syntax. A missing closing tag or malformed regular expression is enough to break the entire file. Reverting to the backup and reintroducing sections one at a time isolates the problem.
AllowOverrideis not permitting a directive used in the file. If the Apache virtual host does not allow a directive like<RequireAll>orOptions, the server returns a500 Internal Server Errorrather than silently ignoring it. Checking the Apache error log (commonly at/var/log/apache2/error.logon Debian-based systems) will usually name the exact directive that failed.
Server logs are the fastest way to confirm whether a given rule is behaving as intended, both when troubleshooting and when verifying a rule is working correctly.
Summary
A hardened .htaccess file can provide an additional layer of server-level protection and optimization for WordPress websites running on Apache. By applying the rules defined in this article, the .htaccess file:
- Blocks malicious traffic before it hits PHP.
- Protects sensitive files and directories.
- Enforces modern security practices.
- Speeds up performance with caching and compression.
When relying on the default WordPress .htaccess file, the website is potentially exposed. Customizing it adds another layer of defense that safeguards the website at the server level, without installing extra plugins or complex tools.