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:

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:

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.com and the IPs and TLDs shown are placeholders. Values should be adjusted for the actual environment. Most of these directives, including <RequireAll>, <FilesMatch>, and Options, require the Apache virtual host to have AllowOverride All (or at minimum AllowOverride 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

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:

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:

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:

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_expires supports wildcard MIME types as of Apache 2.4, so a rule such as ExpiresByType 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:

Fixing a 500 Internal Server Error

A 500 Internal Server Error immediately after updating .htaccess usually indicates one of two issues:

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:

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.