John Dalesandro

Harden WordPress Security Using Custom .htaccess Files

When running a WordPress site or any Apache-powered site, security should never be an afterthought. By default, WordPress ships with a minimal .htaccess file that only manages permalinks. This leaves your site exposed to spam, bots, brute-force attacks, and performance issues.

A custom .htaccess file provides a powerful first line of defense at the server level. These rules stop malicious traffic before it ever reaches PHP or your application. While this guide focuses on WordPress, most examples apply to any Apache site, including PHP applications, static sites, and APIs.

Below, we’ll break down a hardened .htaccess file section by section and explain what each rule does.

Why Use a Custom .htaccess File

The default WordPress .htaccess file only handles permalinks. It does not:

Without these protections, your site remains vulnerable. 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 your website. This is the same folder that contains your main index.php file.

For a typical WordPress installation, this means:

NOTE:

  • The file name begins with a dot (.), which makes it hidden on many systems. In FTP clients or file managers, you may need to enable “show hidden files” to see it.
  • If you don’t see an .htaccess file, WordPress can generate one automatically. Go to Settings > Permalinks in your WordPress dashboard and click Save Changes. This will create a default file if one doesn’t already exist.

Non-WordPress Apache sites also place .htaccess files in the directory where you want the rules to apply. You can have multiple .htaccess files in different folders, each controlling access or behavior for its directory and subdirectories.

Full Hardened .htaccess File

Here’s a complete .htaccess file that strengthens security and performance.

NOTE: The domain name is assumed to be www.example.com and the IPs/TLDs shown are placeholders. Adjust these values for your actual setup.

# =========================
# 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::
</RequireAll>

# =========================
# Protect Sensitive Files
# =========================
<FilesMatch "wp-config\.php|error_log|readme\.html|license\.txt|wp-config-sample\.php|\.htaccess|\.env">
  Require all denied
</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]

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

  # =========================
  # 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/* "access plus 1 year"
  ExpiresByType video/* "access plus 1 year"
  ExpiresByType audio/* "access plus 1 year"
  ExpiresByType font/* "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 It Works

Security & Server Options

Restricting HTTP Methods

Only safe, commonly used HTTP methods (GET, POST, HEAD, OPTIONS) are allowed. This blocks methods like PUT, DELETE, and TRACE, which attackers sometimes use to bypass protections.

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.

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.

Rewrite Rules (Security Filters)

This section applies multiple protections:

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:

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.

Summary

A hardened .htaccess file is one of the simplest and most effective ways to secure and optimize WordPress or any Apache-powered site. By applying the rules defined in this article, the .htaccess file:

If you’re still relying on the default WordPress .htaccess file, you’re leaving your site exposed. By customizing it, you add another layer of defense that safeguards your site at the server level without installing extra plugins or complex tools.