14. Recipes¶
Not to know that no space of regret can make amendsfor one life’s opportunity misused!—Charles Dickens, A Christmas Carol
In this chapter, we’ll present various common problems, and a variety of
ways to solve them using mod_rewrite, or one of the other tools
discussed in this book.
Some of these recipes have already been presented in other parts of the book, but are gathered here to make it easier to find them. We’ll also expand, in detail, how they work, and when you might want to use one solution versus another.
Many of these recipes are drawn from questions that appear regularly on the
users@httpd.apache.org mailing list. They represent real-world problems
that real administrators face every day. Where a question comes up
repeatedly on the mailing list, we note that—it’s a signal that the
existing documentation could do a better job of explaining the solution.
14.1. Common Redirects¶
These are the bread and butter of URL manipulation—the redirects that every web administrator will need at some point.
14.1.1. Redirecting HTTP to HTTPS¶
Problem: You want to force all traffic to use HTTPS. This is by far the most common question on the httpd users mailing list, appearing in dozens of threads over the years. Users frequently struggle with where to put the redirect rules, especially when virtual hosts are involved.
Approach: Redirect (preferred), or mod_rewrite, or <If>
A common pitfall, seen in threads like “Virtual Host - Port 80 to 443,”
is putting SSL directives and rewrite rules in the same <VirtualHost>
block. The correct pattern is to use two virtual host blocks: one for
port 80 that does nothing but redirect, and one for port 443 that holds
the actual site configuration.
The simplest and clearest approach uses two <VirtualHost> blocks and a
single Redirect directive:
# Port 80: redirect everything to HTTPS
<VirtualHost *:80>
ServerName www.example.com
Redirect permanent / https://www.example.com/
</VirtualHost>
# Port 443: the real site
<VirtualHost *:443>
ServerName www.example.com
SSLEngine on
SSLCertificateFile /etc/pki/tls/certs/example.com.crt
SSLCertificateKeyFile /etc/pki/tls/private/example.com.key
DocumentRoot /var/www/html
</VirtualHost>
The Redirect permanent on port 80 sends a 301 for every request,
preserving the original path and query string. The client’s browser will
cache this redirect, so subsequent visits go straight to HTTPS.
Warning
Do not put SSLEngine on and Redirect in the same
<VirtualHost> block. The port-80 block handles plaintext HTTP; the
port-443 block handles TLS. Mixing them is the single most common
mistake seen on the mailing list.
If you need mod_rewrite for this (perhaps because you’re in a
.htaccess file and can’t define virtual hosts):
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
The RewriteCond %{HTTPS} off ensures this only fires for plaintext
connections, preventing a redirect loop. See
Chapter 7 for details on RewriteCond.
Starting with httpd 2.4, you can also use an <If> expression inside
the port-80 virtual host:
<If "%{HTTPS} == 'off'">
Redirect permanent / https://www.example.com/
</If>
The <If> approach reads more naturally than RewriteCond, but
the two-VirtualHost pattern with a bare Redirect remains the
cleanest solution. The <If> form is most useful when you cannot
separate the configuration into two virtual host blocks.
Another common mistake: using _default_:443 as the virtual host
address. This creates a catch-all SSL host that matches any hostname,
which can cause certificate mismatch warnings if you have multiple
domains. Always use an explicit ServerName in your SSL virtual host.
14.1.2. Canonicalizing the Hostname (www vs. non-www)¶
Problem: You want www.example.com and example.com to resolve
to a single canonical URL, to avoid duplicate content in search engines.
This comes up frequently on the mailing list, often intertwined with the
HTTP-to-HTTPS redirect question.
Approach: Separate <VirtualHost> blocks (preferred), or
mod_rewrite with RewriteCond %{HTTP_HOST}
As noted in the “redirect vs. rewrite” thread on the httpd users list,
the recommended approach from experienced responders is to use separate
virtual hosts for hostname canonicalization, rather than RewriteCond.
This keeps the configuration clearer and avoids accidental interactions
with other rewrite rules.
The cleanest approach uses separate <VirtualHost> blocks — one canonical,
one that redirects:
# Redirect non-www to www
<VirtualHost *:443>
ServerName example.com
SSLEngine on
SSLCertificateFile /etc/pki/tls/certs/example.com.crt
SSLCertificateKeyFile /etc/pki/tls/private/example.com.key
Redirect permanent / https://www.example.com/
</VirtualHost>
# Canonical host
<VirtualHost *:443>
ServerName www.example.com
SSLEngine on
SSLCertificateFile /etc/pki/tls/certs/example.com.crt
SSLCertificateKeyFile /etc/pki/tls/private/example.com.key
DocumentRoot /var/www/html
</VirtualHost>
To redirect in the other direction (www → non-www), swap the
ServerName values.
The separate-VirtualHost approach is preferred because:
It makes the intent obvious to anyone reading the config.
The redirect VirtualHost contains no
DocumentRoot, no rewrite rules, and no application config — just a singleRedirect.There’s no risk of rewrite-rule interactions.
If you can’t use separate virtual hosts (e.g., you’re in .htaccess),
use RewriteCond:
RewriteEngine On
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
Note
The [NC] (no-case) flag handles mixed-case hostnames. Without it,
Example.COM would not match.
To combine hostname canonicalization with the HTTPS redirect, put them in order — the HTTPS redirect first, then the hostname redirect:
# In .htaccess or a single VirtualHost:
RewriteEngine On
# Step 1: Force HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Step 2: Force www
RewriteCond %{HTTP_HOST} !^www\. [NC]
RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
This results in at most two redirects for http://example.com/page:
first to https://example.com/page, then to
https://www.example.com/page. If you want a single redirect, combine
the conditions in the VirtualHost approach — the port-80 redirect points
directly to the canonical https://www. URL.
14.1.3. Adding or Removing Trailing Slashes¶
Problem: You want consistent URLs—either always with a trailing
slash or always without. mod_dir’s DirectorySlash directive
interacts with this in ways that confuse many users.
Approach: mod_dir / DirectorySlash, or mod_rewrite
A mailing list thread on “Limiting redirects with rewriterule/rewritecond” discusses combining trailing-slash removal with other rewrites to reduce the number of redirects a client experiences. One respondent notes: “be careful about not creating loops, especially if using .htaccess files.”
Adding a trailing slash is the default behavior of mod_dir. When
a request for /about matches a directory on disk, mod_dir
automatically redirects to /about/. The DirectorySlash directive
controls this:
# Default behavior — adds trailing slash to directories
DirectorySlash On
If you want to enforce trailing slashes on all URLs (not just
directories), use mod_rewrite:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !(.*)/$
RewriteRule ^(.*)$ /$1/ [R=301,L]
The !-f condition prevents adding a slash to actual files (you
don’t want /style.css/).
Removing trailing slashes requires disabling DirectorySlash
and handling it yourself:
DirectorySlash Off
RewriteEngine On
# Remove trailing slash (except for root /)
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)/$ /$1 [R=301,L]
Warning
Setting DirectorySlash Off means mod_dir will no longer
automatically redirect /about to /about/, which can cause
relative links within that directory to break. You must ensure your
application generates absolute URLs or handles this itself.
Avoiding redirect loops: In .htaccess, the URI is re-evaluated
after each internal rewrite. A rule that adds a slash can interact with
mod_dir’s own slash-adding logic, creating a loop. The safest
pattern is:
# In .htaccess — add slash without looping
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} -d
RewriteCond %{REQUEST_URI} !/$
RewriteRule ^ %{REQUEST_URI}/ [R=301,L]
The -d check ensures the rule only fires for actual directories,
and the !/$ condition ensures it doesn’t fire if the slash is
already present. See Chapter 11 for more
on avoiding loops.
14.1.4. Redirecting an Entire Site to a New Domain¶
Problem: You’ve moved your site to a new domain and want to redirect all old URLs to the new domain, preserving the path. This comes up frequently on the mailing list—a thread on “rewrite in .htaccess” shows a user migrating a WordPress site who gets partial redirects because their rewrite rules are in the wrong order.
Approach: Redirect (preferred for simple cases), or mod_rewrite
The key mistake in the mailing list thread: placing the domain-migration
rewrite rules after the WordPress .htaccess rules, which short-circuit
with [L] before the migration rules are reached.
The simplest approach is a single Redirect directive:
<VirtualHost *:80>
ServerName old.example.com
Redirect permanent / https://new.example.com/
</VirtualHost>
This redirects every request while preserving the path.
/blog/my-post?id=42 becomes https://new.example.com/blog/my-post?id=42.
The mod_rewrite equivalent:
<VirtualHost *:80>
ServerName old.example.com
RewriteEngine On
RewriteRule ^ https://new.example.com%{REQUEST_URI} [R=301,L]
</VirtualHost>
Use the mod_rewrite version when you need to add conditions — for
example, redirecting only certain paths or excluding an API endpoint
from the redirect.
Rule ordering with CMS .htaccess files: If you’re migrating a
WordPress (or similar CMS) site, the CMS .htaccess typically
contains:
# WordPress default
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
If you add your domain-migration redirect after these rules, the
[L] flag on the WordPress rules will stop processing before your
redirect is ever reached. Place domain-migration rules before any
CMS rules:
# Domain migration — must come FIRST
RewriteEngine On
RewriteRule ^ https://new.example.com%{REQUEST_URI} [R=301,L]
# WordPress rules below (never reached because of the [L] above)
# ...
Better yet, put the Redirect in server config rather than
.htaccess — it will be processed before any .htaccess rules
are even loaded.
14.1.5. Redirecting Individual Pages That Have Moved¶
Problem: Specific pages have moved to new URLs (site restructure, CMS migration, etc.) and you need 301 redirects for SEO. A thread on “Redirects and rewrites and performance” discusses a site with 10,000 accumulated redirects from years of migrations, raising the question of when performance becomes a concern.
Approach: Redirect / RedirectMatch (preferred), or
RewriteRule, or RewriteMap for large numbers of redirects
For a small number of redirects, Redirect directives are simplest.
For thousands of redirects, use a RewriteMap with a DBM or text file
to avoid loading thousands of individual directives.
For a handful of redirects, use Redirect:
Redirect permanent /old-page.html /new-page.html
Redirect permanent /blog/2019/post /archive/2019/post
For pattern-based redirects, use RedirectMatch:
# Redirect all /blog/YYYY/slug to /archive/YYYY/slug
RedirectMatch permanent ^/blog/([0-9]{4})/(.+)$ /archive/$1/$2
For large numbers of redirects (hundreds or thousands), individual
Redirect or RewriteRule directives become unwieldy and slow.
Use a RewriteMap instead:
# In server config (not .htaccess — RewriteMap can't go there)
RewriteMap redirects "txt:/etc/httpd/conf/redirect-map.txt"
RewriteEngine On
RewriteCond ${redirects:$1} !=""
RewriteRule ^(.+)$ ${redirects:$1} [R=301,L]
The map file is a simple two-column text file:
# /etc/httpd/conf/redirect-map.txt
/old-page.html /new-page.html
/blog/2019/post /archive/2019/post
/products/widget /shop/widgets
For even better performance with thousands of entries, convert the text
map to DBM format using httxt2dbm:
httxt2dbm -i redirect-map.txt -o redirect-map.dbm
Then reference the DBM map:
RewriteMap redirects "dbm:/etc/httpd/conf/redirect-map.dbm"
DBM lookups are O(1) hash-table lookups regardless of map size, while
a text file is scanned linearly. For 10,000+ redirects, the difference
is significant. See Chapter 8 for full
details on RewriteMap types.
14.1.6. Redirecting Wildcard Subdomains¶
Problem: You need to redirect *.oldsite.com to newsite.com,
possibly preserving certain allowed subdomains. A detailed thread on
“Apache Rewrite - Redirect Wildcard Subdomain” shows a user with complex
requirements: some wildcard subdomains should redirect to the base domain,
while others should be preserved on the new domain.
Approach: mod_rewrite with RewriteCond %{HTTP_HOST}
This requires mod_rewrite because Redirect and RedirectMatch
cannot match against the hostname. The key is getting the ServerAlias
right (*.oldsite.com) and using RewriteCond to capture and
selectively route subdomain patterns.
First, ensure DNS is configured with a wildcard record
(*.oldsite.com → your server IP), and that your virtual host
accepts wildcard connections:
<VirtualHost *:80>
ServerName oldsite.com
ServerAlias *.oldsite.com
RewriteEngine On
RewriteRule ^ https://newsite.com%{REQUEST_URI} [R=301,L]
</VirtualHost>
This redirects every subdomain (blog.oldsite.com, shop.oldsite.com,
etc.) to the base domain newsite.com, preserving the path.
Preserving the subdomain on the new domain requires capturing it from
the Host header:
<VirtualHost *:80>
ServerName oldsite.com
ServerAlias *.oldsite.com
RewriteEngine On
RewriteCond %{HTTP_HOST} ^(.+)\.oldsite\.com$ [NC]
RewriteRule ^ https://%1.newsite.com%{REQUEST_URI} [R=301,L]
</VirtualHost>
Here %1 is the first capture group from the RewriteCond — the
subdomain portion.
Selective handling — redirect most subdomains but keep a few:
<VirtualHost *:80>
ServerName oldsite.com
ServerAlias *.oldsite.com
RewriteEngine On
# Don't redirect mail or api subdomains
RewriteCond %{HTTP_HOST} !^(mail|api)\.oldsite\.com$ [NC]
RewriteCond %{HTTP_HOST} ^(.+)\.oldsite\.com$ [NC]
RewriteRule ^ https://%1.newsite.com%{REQUEST_URI} [R=301,L]
</VirtualHost>
The first RewriteCond excludes mail and api; the second
captures the subdomain for redirection. Because multiple
RewriteCond lines before a single RewriteRule are ANDed by
default, both conditions must be true for the rule to fire.
14.2. Clean and Pretty URLs¶
Making URLs user-friendly and hiding implementation details.
14.2.1. Removing File Extensions (.php, .html)¶
Problem: You want /about to serve /about.php without the user
seeing the .php extension. A thread on “Remove .php extension but
still pass it to PHP-FPM” shows this is especially tricky when PHP-FPM is
in the mix, because the proxy handler needs to know the actual file path.
Approach: mod_rewrite (with -f check), or MultiViews
(content negotiation)
MultiViews (enabled via Options +MultiViews) can handle this
without any rewrite rules at all, but its behavior can be surprising and
it has performance implications. The mod_rewrite approach gives more
control.
MultiViews (simplest): Enable content negotiation, and Apache will
automatically serve /about.php when the client requests /about:
<Directory /var/www/html>
Options +MultiViews
</Directory>
That’s it — no rewrite rules needed. Apache looks for files matching the requested path with any known extension and serves the best match.
Note
MultiViews can produce unexpected results if you have both
about.html and about.php — Apache will choose based on content
negotiation headers. It also adds a small overhead because Apache
must scan the directory for matching files on every request.
:module:`mod_rewrite` approach (more control):
RewriteEngine On
# If the request doesn't match an existing file or directory
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# And the request with .php appended IS a real file
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ $1.php [L]
The -f checks are essential. Without the final RewriteCond, a
request for /nonexistent would be rewritten to /nonexistent.php,
which also doesn’t exist, producing a confusing 404.
PHP-FPM / ProxyPassMatch consideration: When PHP is handled by
PHP-FPM via ProxyPassMatch, the proxy handler needs the .php
extension to know which requests to forward:
# Typical PHP-FPM proxy config
ProxyPassMatch ^/(.*\.php(/.*)?)$ fcgi://127.0.0.1:9000/var/www/html/$1
# Extensionless rewrite (in .htaccess or <Directory>)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ $1.php [L]
The rewrite appends .php internally, and then ProxyPassMatch
matches the .php extension and forwards to PHP-FPM. The order
matters: the rewrite happens first (in <Directory> context), then
the proxy match is evaluated against the rewritten URI.
14.2.2. Front Controller Pattern (CMS/Framework Routing)¶
Problem: Your application framework (WordPress, Laravel, Symfony,
etc.) uses a front controller pattern where all requests that don’t match
a real file should be routed to index.php. This is the single most
common .htaccess configuration on the web, and it generates a steady
stream of mailing list questions when it doesn’t work.
Approach: mod_rewrite in .htaccess
The standard WordPress .htaccess pattern is:
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
A thread on “WordPress .htaccess rewrite issue between httpd versions”
documents a rewrite loop that appeared when migrating from httpd 2.4.6 to
2.4.51 with PHP-FPM—the ProxyPassMatch for PHP files interacted
with the .htaccess rewrite rules in an unexpected way, causing the
rewrite to loop infinitely.
Here is the standard pattern, annotated line by line:
# Enable the rewrite engine
RewriteEngine On
# Set the base URL for relative substitutions in .htaccess.
# If your site lives at /blog/, change this to /blog/.
RewriteBase /
# If the request is literally for index.php, stop here.
# The [L] flag means "last rule" — don't process further.
# The - substitution means "don't rewrite, pass through."
RewriteRule ^index\.php$ - [L]
# If the requested file exists on disk, don't rewrite.
RewriteCond %{REQUEST_FILENAME} !-f
# If the requested directory exists on disk, don't rewrite.
RewriteCond %{REQUEST_FILENAME} !-d
# Everything else: rewrite to index.php.
# The "." pattern matches any non-empty URI.
RewriteRule . /index.php [L]
Why the ``^index.php$`` rule? Without it, the rewrite creates a
loop. After the last RewriteRule rewrites to /index.php, the
.htaccess is re-evaluated against the new URI. The first rule
matches index.php and stops, breaking the loop.
What breaks when ``AllowOverride`` is wrong: If the server config has
AllowOverride None (the default in many distributions), .htaccess
files are completely ignored — no errors, no log entries, nothing. The
fix:
<Directory /var/www/html>
AllowOverride FileInfo
</Directory>
FileInfo is the minimum needed for RewriteRule directives.
AllowOverride All also works but grants more than necessary.
What breaks when ``RewriteBase`` is missing: In .htaccess,
mod_rewrite strips the directory prefix from the URI before matching,
then prepends it back after substitution. RewriteBase tells it what
to prepend. If omitted and the site is in a subdirectory, rewrites
produce incorrect paths. For a site at http://example.com/blog/,
you need RewriteBase /blog/.
The PHP-FPM loop interaction: When PHP runs via ProxyPassMatch:
ProxyPassMatch ^/(.*\.php(/.*)?)$ fcgi://127.0.0.1:9000/var/www/html/$1
A request for /my-page gets rewritten to /index.php by the
.htaccess rules, then ProxyPassMatch forwards it to PHP-FPM.
PHP-FPM processes it and returns a response. The loop occurs if
ProxyPassMatch triggers a subrequest that re-enters .htaccess
processing. In httpd 2.4.51+, the PT (passthrough) flag behavior
changed slightly, which can trigger this. The fix: use SetHandler
instead of ProxyPassMatch:
<FilesMatch "\.php$">
SetHandler "proxy:fcgi://127.0.0.1:9000"
</FilesMatch>
SetHandler avoids the regex-matching path entirely and doesn’t
trigger rewrite re-entry.
14.2.3. Mapping Clean URL Paths to Query Parameters¶
Problem: You want /products/widget-42 to internally map to
/product.php?id=widget-42. This is a classic mod_rewrite use case
and appears in many mailing list threads. A common mistake (seen in
“RewriteRule not working, 404 error obtained”) is that AllowOverride
is not set correctly, so the .htaccess rules are silently ignored.
Approach: mod_rewrite
Simple one-segment mapping — /products/shoes to
/products.php?cat=shoes:
RewriteEngine On
RewriteRule ^products/([a-zA-Z0-9_-]+)$ /products.php?cat=$1 [L]
The $1 backreference captures whatever matched inside the
parentheses. See Chapter 1 for regex details.
Multi-segment paths — /products/shoes/running to
/products.php?cat=shoes&sub=running:
RewriteRule ^products/([^/]+)/([^/]+)$ /products.php?cat=$1&sub=$2 [L]
With optional segments — /products/shoes or
/products/shoes/running:
RewriteRule ^products/([^/]+)(/([^/]+))?$ /products.php?cat=$1&sub=$3 [L]
If the second segment is absent, $3 is empty and the query parameter
sub= has no value, which the application should handle.
The ``AllowOverride`` pitfall: If these rules are in .htaccess
and AllowOverride does not include FileInfo, the rules are
silently ignored. There’s no error message, no log entry — the
.htaccess file simply has no effect. Enable tracing to confirm
rules are being processed:
# In server config
<Directory /var/www/html>
AllowOverride FileInfo
</Directory>
# Temporarily enable rewrite logging
LogLevel alert rewrite:trace3
If you see no rewrite log entries at all for a request that should match
your .htaccess rules, AllowOverride is the likely culprit.
Tip
These patterns assume mod_rewrite in .htaccess. In server
config (<VirtualHost> or <Directory>), the URI includes the
leading slash, so the pattern becomes ^/products/([^/]+)$. See
Chapter 11 for the full list of
context differences.
14.3. Access Control¶
Using URL manipulation for access control purposes. (Note: mod_rewrite
is generally not the best tool for access control—Require,
<If>, and mod_authz_* are usually better choices.)
14.3.1. Blocking Hotlinking (Referrer-Based Access)¶
Problem: Other sites are embedding your images directly, consuming your bandwidth. You want to block or redirect requests that come from other domains.
Approach: mod_rewrite with RewriteCond %{HTTP_REFERER}
(traditional), or <If> expression (modern, preferred)
:module:`mod_rewrite` approach (traditional):
RewriteEngine On
RewriteCond %{HTTP_REFERER} !^$ [NC]
RewriteCond %{HTTP_REFERER} !^https?://(www\.)?example\.com [NC]
RewriteRule \.(jpg|jpeg|png|gif|webp|svg)$ - [F]
Line by line:
!^$— allow empty referrers (direct visits, bookmarks, privacy extensions). Without this, your own users following bookmarks would be blocked.!^https?://(www\.)?example\.com— allow requests from your own domain.The
RewriteRulematches image extensions and returns 403 Forbidden ([F]).
To serve a placeholder image instead of a 403:
RewriteRule \.(jpg|jpeg|png|gif|webp|svg)$ /images/hotlink-notice.png [L]
SetEnvIf + Require approach (modern, preferred):
SetEnvIf Referer "^$" local_ref
SetEnvIf Referer "^https?://(www\.)?example\.com" local_ref
<FilesMatch "\.(jpg|jpeg|png|gif|webp|svg)$">
Require env local_ref
</FilesMatch>
This approach is cleaner — the access control logic lives in the authorization layer where it belongs, not in URL rewriting.
Warning
The Referer header is trivially spoofed and frequently absent.
Privacy-focused browsers, extensions, and corporate proxies strip it.
Hotlink protection is a deterrent, not a security boundary. Don’t use
it to protect genuinely sensitive content — use authentication instead.
14.3.2. Blocking Requests by User-Agent¶
Problem: You want to block specific bots, scrapers, or vulnerability scanners based on their User-Agent string. Several mailing list threads discuss this in the context of “Unknown accepted traffic” and bot mitigation.
Approach: mod_rewrite with RewriteCond %{HTTP_USER_AGENT}
(traditional), or <If> / SetEnvIf with Require (modern,
preferred)
SetEnvIf + Require approach (recommended):
SetEnvIf User-Agent "BadBot" bad_bot
SetEnvIf User-Agent "EvilScraper" bad_bot
SetEnvIf User-Agent "VulnScanner" bad_bot
<Directory /var/www/html>
<If "reqenv('bad_bot') != ''">
Require all denied
</If>
</Directory>
Or more concisely with Require:
SetEnvIf User-Agent "BadBot|EvilScraper|VulnScanner" bad_bot
<Directory /var/www/html>
Require not env bad_bot
</Directory>
:module:`mod_rewrite` approach (traditional):
RewriteEngine On
RewriteCond %{HTTP_USER_AGENT} (BadBot|EvilScraper|VulnScanner) [NC]
RewriteRule ^ - [F]
The SetEnvIf approach is preferred because:
It separates identification (
SetEnvIf) from authorization (Require), making the config easier to read and maintain.The
Requiredirective integrates with httpd’s authorization framework, producing proper 403 responses with correct logging.mod_rewrite’s[F]flag works, but using the rewrite engine for access control is using the wrong tool for the job.
Note
User-Agent strings are trivially spoofed. Any bot that wants to evade detection can send a browser-like User-Agent. This technique is useful against lazy bots and automated scanners but is not a substitute for rate limiting or WAF rules.
14.3.4. IP-Based Access Control¶
Problem: You want to restrict access to certain paths based on client IP address.
Approach: Require ip (strongly preferred), <If> expressions,
or mod_rewrite with RewriteCond %{REMOTE_ADDR} (not recommended)
Require ip (strongly preferred):
<Location /admin>
Require ip 10.0.0.0/8
Require ip 192.168.1.0/24
Require ip 2001:db8::/32
</Location>
To allow a specific IP and deny everything else:
<Location /admin>
Require ip 10.1.2.3
</Location>
To combine IP restrictions with authentication (require both):
<Location /admin>
<RequireAll>
Require ip 10.0.0.0/8
Require valid-user
</RequireAll>
</Location>
<If> expression approach:
<If "%{REMOTE_ADDR} -ipmatch '10.0.0.0/8'">
# Allowed
</If>
<Else>
Require all denied
</Else>
:module:`mod_rewrite` approach (do not use for this):
# This works, but don't do it
RewriteEngine On
RewriteCond %{REMOTE_ADDR} !^10\.
RewriteRule ^/admin - [F]
This is worse than Require ip in every way:
The regex
!^10\.is an approximation of10.0.0.0/8— it doesn’t actually do CIDR matching, so it’s easy to get wrong.It bypasses the authorization framework, so
Requiredirectives in the same scope may not behave as expected.It doesn’t log the denial through the standard authorization log.
It doesn’t support IPv6 CIDR notation.
Use Require ip. It exists precisely for this purpose, handles CIDR
correctly for both IPv4 and IPv6, integrates with the authorization
framework, and is far easier to read and audit.
14.4. Proxying¶
Rewriting in the context of reverse proxying is a common source of confusion, as the mailing list amply demonstrates.
14.4.1. Rewriting URLs for a Reverse Proxy Backend¶
Problem: Your reverse proxy needs to strip or add a path prefix when
forwarding requests to a backend application. A detailed thread on
“mod_proxy_http rewrite problem” shows a user struggling with balancer
configuration where the rewrite rule incorrectly strips the application
context path, causing authentication to break.
Approach: ProxyPass path mapping (preferred), or mod_rewrite
with [P] flag
The recommended approach is to let ProxyPass and ProxyPassReverse
handle the path mapping. Using mod_rewrite with [P] should be a
last resort, because it bypasses the connection pooling and other
optimizations of mod_proxy.
ProxyPass path mapping (preferred):
# Forward /app/ to a backend running on port 8080 at /
ProxyPass /app/ http://backend.local:8080/
ProxyPassReverse /app/ http://backend.local:8080/
ProxyPass maps the incoming path to the backend path.
ProxyPassReverse rewrites Location headers in the backend’s
responses so that redirects issued by the backend (e.g.,
Location: http://backend.local:8080/login) are translated back to
the client-facing URL (/app/login).
Stripping a prefix:
# Client requests /api/v2/users
# Backend expects /v2/users (no /api prefix)
ProxyPass /api/ http://backend.local:8080/
ProxyPassReverse /api/ http://backend.local:8080/
The path mapping in ProxyPass handles the prefix stripping
automatically — /api/v2/users becomes /v2/users on the backend.
:module:`mod_rewrite` with [P] flag (last resort):
RewriteEngine On
RewriteRule ^/app/(.*)$ http://backend.local:8080/$1 [P]
ProxyPassReverse /app/ http://backend.local:8080/
Warning
The [P] flag forces the request through mod_proxy, but it
bypasses ProxyPass’s connection pooling and worker
configuration. Each [P] request creates a new connection to the
backend. For high-traffic sites, this is significantly less efficient.
You still need ProxyPassReverse even when using [P] — the
flag handles the request path but not the response headers.
Do not mix ProxyPass and RewriteRule [P] for the same path:
# WRONG — these will conflict
ProxyPass /app/ http://backend.local:8080/
RewriteRule ^/app/special/(.*)$ http://other-backend:8080/$1 [P]
ProxyPass is processed before RewriteRule in most contexts,
so the rewrite rule may never be reached. If you need conditional
proxying, use RewriteRule [P] for all paths in that scope, or use
ProxyPass with <Location> blocks and <If> conditions.
14.4.2. Redirects Behind a TLS-Terminating Proxy¶
Problem: Your httpd sits behind a load balancer or CDN that terminates
TLS. The %{HTTPS} variable is always off from httpd’s perspective,
causing redirect loops when you try to force HTTPS. A thread on
“Configuring redirects httpd behind a TLS-terminating proxy” discusses
this exact scenario.
Approach: mod_rewrite with RewriteCond %{HTTP:X-Forwarded-Proto}
or <If> with req('X-Forwarded-Proto')
When httpd sits behind a load balancer or CDN that terminates TLS, the
connection between the proxy and httpd is plain HTTP. From httpd’s
perspective, %{HTTPS} is always off. The standard “force HTTPS”
redirect creates an infinite loop:
# This loops behind a TLS-terminating proxy!
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
The fix: check the X-Forwarded-Proto header set by the proxy instead:
RewriteEngine On
RewriteCond %{HTTP:X-Forwarded-Proto} =http
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
Or with an <If> expression (cleaner):
<If "req('X-Forwarded-Proto') == 'http'">
Redirect permanent / https://www.example.com/
</If>
Using :module:`mod_remoteip` to make %{HTTPS} work correctly:
mod_remoteip can be configured to trust the proxy’s forwarded
headers, allowing the standard %{HTTPS} variable to reflect the
client’s actual connection:
# Trust the proxy at 10.0.0.0/8
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 10.0.0.0/8
Note that mod_remoteip handles the client IP (X-Forwarded-For),
not the protocol. For protocol detection, you still need to check
X-Forwarded-Proto explicitly. Some setups use
RequestHeader set X-Forwarded-Proto "https" on the proxy side and
then check it on the httpd side.
Warning
Only trust X-Forwarded-Proto from known proxies. If a client
sends this header directly (bypassing the proxy), they can trick
httpd into thinking the connection is secure. Use firewall rules
or <If> conditions to ensure only your proxy can set this header.
14.4.3. WebSocket Proxying¶
Problem: Your application uses WebSockets and you need to proxy
ws:// or wss:// traffic through httpd. A recurring thread on
“Web sockets & proxypass - No protocol handler was valid for the URL”
shows users struggling to get mod_proxy_wstunnel working.
Approach: mod_proxy_wstunnel with ProxyPass, sometimes
combined with mod_rewrite for upgrade detection
Basic WebSocket proxy with :module:`mod_proxy_wstunnel`:
# Enable required modules
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_wstunnel_module modules/mod_proxy_wstunnel.so
# Proxy WebSocket connections at /ws/ to the backend
ProxyPass /ws/ ws://backend.local:8080/ws/
ProxyPassReverse /ws/ ws://backend.local:8080/ws/
For secure WebSockets (wss://), the TLS termination happens at httpd;
the backend connection can remain plain ws://:
<VirtualHost *:443>
SSLEngine on
# ... SSL config ...
# Client connects via wss://, httpd proxies as ws://
ProxyPass /ws/ ws://backend.local:8080/ws/
ProxyPassReverse /ws/ ws://backend.local:8080/ws/
</VirtualHost>
Upgrade detection with :module:`mod_rewrite` — for applications where the same URL handles both HTTP and WebSocket (e.g., Socket.IO):
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteRule ^/app/(.*)$ ws://backend.local:8080/$1 [P,L]
# Non-WebSocket requests go through normal HTTP proxy
ProxyPass /app/ http://backend.local:8080/
ProxyPassReverse /app/ http://backend.local:8080/
The RewriteCond checks for the Upgrade: websocket header that
initiates the WebSocket handshake. Only those requests are routed to the
ws:// backend; everything else goes through the normal ProxyPass.
Common pitfalls:
“No protocol handler was valid for the URL” —
mod_proxy_wstunnelis not loaded. AddLoadModule proxy_wstunnel_module.Timeout disconnects — WebSocket connections are long-lived. Set a higher timeout:
ProxyTimeout 600 ProxyPass /ws/ ws://backend.local:8080/ws/ timeout=600
httpd 2.4.47+ added
ProxyWebsocketFallbackToProxyHttpwhich allowsmod_proxy_httpto handle WebSocket upgrades directly, withoutmod_proxy_wstunnel. If you’re on a recent version:ProxyPass /ws/ http://backend.local:8080/ws/ upgrade=websocket
The
upgrade=websocketparameter tellsmod_proxy_httpto handle theUpgradeheader and tunnel the connection.
14.5. Query String Manipulation¶
Rewriting query strings requires special techniques because the query
string is not part of the URL path that RewriteRule matches against.
14.5.1. Capturing and Rewriting Query Strings¶
Problem: You need to rewrite based on query string parameters, or
transform query strings during a redirect. A thread on “Mod_rewrite
too many redirects” shows a user trying to redirect /?1234ab to
/welcome?trackFor=0&trackNo=1234ab and getting a redirect loop.
Approach: mod_rewrite with RewriteCond %{QUERY_STRING} and
[QSA] or [QSD] flags
The solution from the mailing list: the original RewriteRule ^(.*)$
pattern was matching the redirect target as well, causing an infinite
loop. Changing to ^/$ (matching only the root) fixed the loop.
RewriteRule matches only the URL path—it never sees the query
string. To match or capture query string parameters, use
RewriteCond %{QUERY_STRING}:
# Redirect /?code=ABC123 to /welcome?track=ABC123
RewriteEngine On
RewriteCond %{QUERY_STRING} ^code=([a-zA-Z0-9]+)$
RewriteRule ^/?$ /welcome?track=%1 [R=301,L]
Here %1 is a backreference to the first capture group in the
RewriteCond pattern (not $1, which refers to the RewriteRule
pattern). See Chapter 1 for the full
backreference syntax.
The [QSA] flag (Query String Append): by default, if the
RewriteRule substitution contains a query string, it replaces the
original. [QSA] appends the original query string to the new one
instead:
# /products/widget?color=red
# Without QSA -> /catalog.php?item=widget (color=red is lost)
# With QSA -> /catalog.php?item=widget&color=red
RewriteRule ^/products/(.+)$ /catalog.php?item=$1 [QSA,L]
The [QSD] flag (Query String Discard): removes the query
string entirely from the rewritten URL. See the next recipe for details.
Avoiding redirect loops with query strings: The most common loop
occurs when the RewriteRule pattern is too broad:
# BUG: ^(.*)$ matches /welcome too, causing a loop
RewriteCond %{QUERY_STRING} ^code=(.+)$
RewriteRule ^(.*)$ /welcome?track=%1 [R=301,L]
# FIX: match only the specific source URL
RewriteCond %{QUERY_STRING} ^code=(.+)$
RewriteRule ^/?$ /welcome?track=%1 [R=301,L]
Another anti-loop technique is to add a condition that checks whether the rewrite has already happened:
RewriteCond %{QUERY_STRING} ^code=(.+)$
RewriteCond %{QUERY_STRING} !track= # don't re-rewrite
RewriteRule ^/?$ /welcome?track=%1 [R=301,L]
14.5.2. Stripping Query Strings¶
Problem: You want to remove query strings from URLs, either for SEO cleanliness or to prevent parameter injection, but you need to preserve query strings on certain specific URLs. A thread on “Stripping query string except from specific URL” shows this exact use case.
Approach: mod_rewrite with [QSD] flag and RewriteCond
exceptions
Blanket query string removal with [QSD] (Query String Discard,
available since httpd 2.4.0):
# Strip query strings from all requests
RewriteEngine On
RewriteCond %{QUERY_STRING} .
RewriteRule ^ %{REQUEST_URI} [QSD,R=301,L]
The RewriteCond ensures this only fires when a query string is
actually present, avoiding a redirect loop on requests that already
have no query string.
Excluding specific paths from query string stripping:
RewriteEngine On
# Don't strip query strings from the search page or API
RewriteCond %{REQUEST_URI} !^/search
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{QUERY_STRING} .
RewriteRule ^ %{REQUEST_URI} [QSD,R=301,L]
The pre-2.4 method (the trailing ? trick): Before [QSD]
existed, you discarded the query string by appending a bare ? to
the substitution target:
# Old method: trailing ? discards the original query string
RewriteRule ^/old-page$ /new-page? [R=301,L]
This works because the ? starts a new (empty) query string,
replacing the original. It still works in 2.4+, but [QSD] is
clearer about intent:
# Equivalent, but more readable
RewriteRule ^/old-page$ /new-page [QSD,R=301,L]
Stripping only specific parameters (keeping the rest):
# Remove the 'fbclid' tracking parameter, keep everything else
RewriteEngine On
RewriteCond %{QUERY_STRING} ^(.*)(?:^|&)fbclid=[^&]*(.*)$
RewriteRule ^ %{REQUEST_URI}?%1%2 [R=301,L]
This is fiddly regex work. For stripping multiple tracking parameters
(utm_*, fbclid, gclid), consider whether a
RewriteMap prg: script would be more maintainable than a wall of
regex.
14.5.3. Using SetEnvIf with Query Strings¶
Problem: You need to set environment variables or control logging based on query string parameters. A thread on “Using SetEnvIf for query string” shows a user trying to conditionally set environment variables.
Approach: SetEnvIf with QUERY_STRING variable, or
mod_rewrite with [E=VAR:value] flag
SetEnvIf can match against Query_String (note the underscore)
to set environment variables without involving mod_rewrite at all:
# Set an environment variable when a debug parameter is present
SetEnvIf Query_String "debug=true" IS_DEBUG
# Suppress logging for health-check requests with ?ping
SetEnvIf Query_String "^ping$" no_log
# Use the no_log variable to exclude from access log
CustomLog /var/log/httpd/access_log combined env=!no_log
Conditional cache headers based on query string:
# Don't cache URLs with a session ID in the query string
SetEnvIf Query_String "sid=" NO_CACHE
Header set Cache-Control "no-store" env=NO_CACHE
:module:`mod_rewrite` [E=] flag — set environment variables during
rewrite processing:
# Tag requests with a tracking parameter
RewriteEngine On
RewriteCond %{QUERY_STRING} utm_source=([^&]+)
RewriteRule ^ - [E=TRACKING_SOURCE:%1]
# Use the variable in a log format
# In server config:
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{TRACKING_SOURCE}e\"" tracking
CustomLog /var/log/httpd/tracking.log tracking env=TRACKING_SOURCE
The - target means “don’t rewrite the URL”—just set the variable
and move on.
Note
In .htaccess context, environment variables set by [E=] are
prefixed with REDIRECT_ after the rewrite completes. So
TRACKING_SOURCE becomes REDIRECT_TRACKING_SOURCE. This catches
many people off guard. To access it in later directives, use
%{ENV:REDIRECT_TRACKING_SOURCE} or set it with SetEnv to copy
it to a non-prefixed name.
When to use which: Use SetEnvIf when you just need to set a
variable based on a simple pattern match—it’s faster and clearer. Use
[E=] when you’re already writing rewrite rules and need to capture
part of the query string into a variable using backreferences.
14.6. Edge Cases and Gotchas¶
These recipes address the tricky situations that generate the most confused questions on the mailing list.
14.6.1. Diagnosing and Fixing Rewrite Loops¶
Problem: Your rewrite rules produce “too many redirects” errors or infinite internal loops. This is the single most common class of rewrite problem on the mailing list. Threads include “Mod_rewrite too many redirects,” “SSO Kerberos REMOTE_USER RewriteRule Endless Loop,” and “WordPress .htaccess rewrite issue between httpd versions.”
Approach: RewriteLog / LogLevel rewrite:trace, plus rule
design patterns to break loops
The Kerberos/SSO thread shows a particularly interesting edge case: a
RewriteCond %{LA-U:REMOTE_USER} rule that works for 97% of users
but creates an endless loop for the other 3%, due to special characters
in certain usernames interacting with the lookahead mechanism.
Step 1: Enable the rewrite log.
LogLevel warn rewrite:trace3
Reproduce the “too many redirects” error and examine the error log. You’ll see the rewrite engine processing the same URI repeatedly.
Step 2: Identify the looping rule.
Look for a pattern like this in the log:
rewrite 'welcome' -> '/index.php'
rewrite 'index.php' -> '/index.php'
rewrite 'index.php' -> '/index.php'
...
The repeated line tells you which rule is firing in a loop and what it’s matching.
Step 3: Fix it. The cause is almost always one of these patterns:
Pattern A — Overly broad rule matching its own target:
# BUG: ^(.*)$ matches everything, including /welcome itself
RewriteRule ^(.*)$ /welcome [R=301,L]
# Request for /about -> 301 /welcome -> 301 /welcome -> loop!
# FIX: add a condition to exclude the target
RewriteCond %{REQUEST_URI} !^/welcome$
RewriteRule ^(.*)$ /welcome [R=301,L]
Pattern B — .htaccess re-processing after rewrite:
# BUG: in .htaccess, [L] restarts processing from the top
RewriteRule ^old-page$ /new-page [R=301,L]
# If /new-page also lives under this .htaccess, the rules re-run
# FIX: the rule only matches 'old-page', so /new-page won't match.
# But if you used ^(.*)$, you need a stop condition:
RewriteCond %{ENV:REDIRECT_STATUS} ^$
RewriteRule ^(.*)$ /new-page [R=301,L]
The REDIRECT_STATUS variable is empty on the first pass and set to
the status code (e.g., 200) on subsequent passes. This is a reliable
way to detect re-processing.
Pattern C — Query string rewrite creates a loop:
# BUG: rewrites /?code=123 to /welcome?track=123
# then /welcome?track=123 matches ^(.*)$ again
RewriteCond %{QUERY_STRING} ^code=(.+)$
RewriteRule ^(.*)$ /welcome?track=%1 [R=301,L]
# FIX: match only the original URL, not the rewritten one
RewriteCond %{QUERY_STRING} ^code=(.+)$
RewriteRule ^/?$ /welcome?track=%1 [R=301,L]
Pattern D — WordPress/PHP-FPM interaction:
When PHP runs as a ProxyPassMatch backend, the proxy subrequest can
re-trigger .htaccess rewrite rules:
# In server config
ProxyPassMatch "^/(.*\.php)$" "fcgi://127.0.0.1:9000/var/www/html/$1"
# In .htaccess (WordPress default)
RewriteRule . /index.php [L]
The rewrite sends the request to /index.php. The ProxyPassMatch
proxies it to PHP-FPM. But the proxy subrequest re-enters the
.htaccess processing, and . matches index.php again.
Fix: add a condition to skip the rewrite if the request is already for a
PHP file, or use SetHandler instead of ProxyPassMatch:
<FilesMatch "\.php$">
SetHandler "proxy:fcgi://127.0.0.1:9000"
</FilesMatch>
The nuclear option: httpd’s built-in loop detection stops processing
after 10 internal redirects (configurable with LimitInternalRecursion).
If you see Request exceeded the limit of 10 internal redirects in the
error log, you have a loop.
14.6.2. .htaccess vs. Server Config Context Differences¶
Problem: Your rewrite rules work in httpd.conf but not in
.htaccess (or vice versa). This is one of the most frequent sources
of confusion on the mailing list. In “Rewrite not applied?” a user has
rules in server config that are silently not being evaluated, with no
log entries even at rewrite:trace5.
Approach: Understanding the per-dir context
The key differences:
In
.htaccess, the leading slash is stripped from the URI before matchingRewriteBasematters in.htaccessbut not in server configAllowOverridemust includeFileInfofor rewrite rules to work in.htaccess[L]in.htaccessdoesn’t truly stop processing—the rewritten URL goes through the entire.htaccessagain
Side-by-side: the same rewrite in both contexts.
Rewriting /products/widget to /catalog.php?item=widget:
# In server config (httpd.conf or <VirtualHost>)
RewriteEngine On
RewriteRule ^/products/(.+)$ /catalog.php?item=$1 [L]
# In .htaccess (at document root)
RewriteEngine On
RewriteRule ^products/(.+)$ catalog.php?item=$1 [L]
Key difference: in .htaccess, the leading slash is stripped. The
rewrite engine operates on the path relative to the directory containing
the .htaccess file.
RewriteBase tells mod_rewrite what URL prefix corresponds to the
directory containing the .htaccess file. It matters when your
.htaccess is in a subdirectory:
# .htaccess in /var/www/html/myapp/
# Without RewriteBase, the rewrite target is relative to /
# With it, the target is relative to /myapp/
RewriteEngine On
RewriteBase /myapp/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
# Resolves to /myapp/index.php, not /index.php
Without RewriteBase /myapp/, the rule would produce /index.php
(at the document root) instead of /myapp/index.php.
The AllowOverride pitfall: If AllowOverride does not
include FileInfo, .htaccess rewrite rules are silently ignored.
No error, no log entry, nothing—the rules simply don’t run:
# This silently disables ALL .htaccess rewrite rules
<Directory "/var/www/html">
AllowOverride None
</Directory>
# This enables them
<Directory "/var/www/html">
AllowOverride FileInfo
</Directory>
# Or allow everything (common but less secure)
<Directory "/var/www/html">
AllowOverride All
</Directory>
Diagnostic tip: If your .htaccess rules do nothing and even
LogLevel rewrite:trace8 produces no output for that directory, check
AllowOverride first. The rewrite engine isn’t ignoring your
rules—it was never invoked.
Performance note: .htaccess files are read on every request.
The server walks the directory tree from the document root to the
requested file, reading each .htaccess file along the way. For
high-traffic sites, putting rules in server config (which is parsed
once at startup) is measurably faster.
14.6.3. Rule Ordering and the [L] Flag¶
Problem: Your rewrite rules aren’t behaving as expected because of
ordering issues. The “rewrite in .htaccess” thread shows a user whose
domain migration redirect is placed after WordPress rewrite rules that
include [L]—the migration rules are never reached.
Approach: Understanding rule processing order
How [L] works in server config: It means “stop processing
rules now.” The rewritten URL is the final result.
How [L] works in .htaccess: It means “stop processing
rules for this pass.” The rewritten URL then goes back through the
entire .htaccess file from the top. This re-processing continues until
no rule matches, or until mod_rewrite’s internal redirect limit (10 by
default) is reached.
This is the single most common source of confusion in mod_rewrite.
Example: The WordPress .htaccess pattern:
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
Here’s what happens with a request for /about:
Pass 1:
/aboutdoesn’t match^index\.php$. It does match.(with the conditions satisfied). Rewritten to/index.php.[L]stops this pass.Pass 2:
/index.phpmatches^index\.php$. The-target means “don’t rewrite.”[L]stops this pass.Pass 3: No rule changes the URI. Processing ends.
The ^index\.php$ - [L] rule exists solely to break the re-processing
loop. Without it, . would match index.php again, rewriting it to
/index.php… which would match . again, and so on until the
redirect limit.
Rule processing order between server config and .htaccess:
Server config (
httpd.conf/<VirtualHost>) rules run firstThen per-directory
.htaccessrules run[L]in server config does not prevent.htaccessrules from running
RewriteCond binds only to the immediately following RewriteRule:
# WRONG: this condition does NOT apply to both rules
RewriteCond %{HTTP_HOST} ^www\.example\.com$
RewriteRule ^/foo /bar [L]
RewriteRule ^/baz /qux [L] # No condition! Matches all hostnames.
# CORRECT: repeat the condition for each rule
RewriteCond %{HTTP_HOST} ^www\.example\.com$
RewriteRule ^/foo /bar [L]
RewriteCond %{HTTP_HOST} ^www\.example\.com$
RewriteRule ^/baz /qux [L]
This surprises people who expect conditions to act like if blocks in
programming languages. Each RewriteCond is consumed by the first
RewriteRule that follows it.
14.6.4. Debugging Rewrite Rules with the Rewrite Log¶
Problem: You can’t figure out why your rewrite rules aren’t doing what you expect. The thread on “Redirects and rewrites and performance” shows a user asking how to trace a specific redirect without overwhelming the log with data from the entire site.
Approach: LogLevel rewrite:trace1 through rewrite:trace8
Enable the rewrite log by setting LogLevel for the rewrite module:
# Useful levels:
# rewrite:trace1 --- shows which rules match
# rewrite:trace2 --- adds rewriting result
# rewrite:trace3 --- adds condition evaluation (the sweet spot)
# rewrite:trace4-8 --- increasingly verbose internals
LogLevel warn rewrite:trace3
Warning
Never leave rewrite:trace3 or higher enabled in production.
It generates enormous amounts of log data—one entry per condition
per rule per request. Enable it temporarily for debugging, then
turn it off.
Reading the log output: At trace3, each request produces entries
like this:
[rewrite:trace3] [pid 1234] mod_rewrite.c(475): [client 10.0.0.1:54321]
10.0.0.1 - - [perdir /var/www/html/] strip per-dir prefix: /var/www/html/about -> about
[rewrite:trace3] [pid 1234] mod_rewrite.c(475): [client 10.0.0.1:54321]
10.0.0.1 - - [perdir /var/www/html/] applying pattern '^index\.php$' to uri 'about'
[rewrite:trace2] [pid 1234] mod_rewrite.c(475): [client 10.0.0.1:54321]
10.0.0.1 - - [perdir /var/www/html/] rewrite 'about' -> '/index.php'
Key things to look for:
strip per-dir prefix— confirms you’re in per-directory (.htaccess) contextapplying pattern— shows which rule is being tested against what URIrewrite '...' -> '...'— the actual rewrite resultIf you see the same URI being rewritten repeatedly, you have a loop
Filtering the log to focus on specific URLs:
You can set the log level per-directory or per-location:
# Only trace rewrites for the /api/ path
<Location "/api/">
LogLevel warn rewrite:trace3
</Location>
Or filter the log file after the fact:
# Show only entries for a specific URI
grep 'about-us' /var/log/httpd/error_log | grep rewrite
# Show only the rewrite results (not every condition check)
grep 'rewrite:trace2' /var/log/httpd/error_log
# Watch in real time
tail -f /var/log/httpd/error_log | grep rewrite
Tip: When debugging in .htaccess, remember that [L] does not
stop processing—it restarts the rule set. So you’ll see the same URI
processed multiple times in the log. This is normal behavior, not a bug.
See the recipe on Rule Ordering and the [L] Flag
earlier in this chapter.
Note
In httpd 2.2 and earlier, the rewrite log was configured with the
separate RewriteLog and RewriteLogLevel directives. These were
removed in 2.4 in favor of the unified LogLevel mechanism.
14.6.5. Serving a Fallback Resource When a File Is Missing¶
Problem: You want to serve a default image, page, or resource when the requested file doesn’t exist. A thread on “Show Alternate Image if Requested Image is Missing” shows a user trying to display a placeholder sketch image when the actual property sketch JPG is missing.
Approach: mod_rewrite with RewriteCond %{REQUEST_FILENAME} !-f
(traditional), or FallbackResource directive (modern, preferred)
The mailing list thread reveals the common mistake: mixing Redirect
(which doesn’t check file existence) with RewriteCond conditions.
The FallbackResource directive, available since 2.2.16, is often the
simplest solution.
FallbackResource (simplest—no mod_rewrite needed):
# Serve /index.html for any request that doesn't match a real file
FallbackResource /index.html
This is ideal for single-page applications (React, Vue, Angular) that handle routing client-side.
:module:`mod_rewrite` approach (when you need more control):
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
This does the same thing but with three lines instead of one. Use it when you need additional conditions (e.g., excluding API paths from the fallback).
Image fallback — serve a placeholder when the requested image doesn’t exist:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule \.(jpg|jpeg|png|gif|webp)$ /images/placeholder.png [L]
This checks whether the requested file exists (!-f), and if it’s an
image extension that’s missing, serves a placeholder instead of a 404.
More targeted fallback — only for a specific directory:
<Directory "/var/www/html/sketches">
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule \.jpg$ /sketches/no-sketch-available.jpg [L]
</Directory>
Common mistake: Combining Redirect with RewriteCond. The
Redirect directive (from mod_alias) does not respect
RewriteCond—it fires unconditionally. If you need a conditional
redirect, you must use RewriteRule with [R].
14.6.7. Handling Special Characters and Encoded URLs¶
Problem: Rewrite rules break when URLs contain special characters
(spaces as %20, international characters, backslashes, etc.). A thread
on “404 rewrite error using special character \” shows issues with
backslashes, while “chinese char URL encoding/decoding fails” documents
problems with multibyte character encoding.
Approach: mod_rewrite with [B] flag and [NE] flag
How httpd handles URL encoding: Before rewrite rules see the URI,
httpd decodes percent-encoded characters. So a request for /caf%C3%A9
arrives at the rewrite engine as /café. This is usually helpful, but
it creates problems when backreferences are substituted into the
target—the decoded characters may need to be re-encoded.
The [B] flag (escape backreferences): tells the rewrite engine
to percent-encode special characters in backreferences before substituting
them into the target. Without it, spaces, ampersands, and other special
characters in captured groups can break the resulting URL.
# Without [B]: /search/hello world -> /results?q=hello world (broken URL)
# With [B]: /search/hello world -> /results?q=hello%20world (correct)
RewriteRule ^/search/(.*)$ /results?q=$1 [B,L]
The [NE] flag (no-escape output): prevents the rewrite engine
from escaping special characters in the output. Useful when your
target URL intentionally contains characters like # (fragment
identifiers) or % (already-encoded sequences):
# Redirect to a URL with a fragment identifier
# Without [NE]: /old#section -> /new%23section (broken)
# With [NE]: /old#section -> /new#section (correct)
RewriteRule ^/old$ /new#section [NE,R=301,L]
# Preserve already-encoded characters in a redirect
# Without [NE]: %20 gets double-encoded to %2520
RewriteRule ^/(.*)$ https://new.example.com/$1 [NE,R=301,L]
Encoded slashes (%2F): By default, httpd rejects URLs containing
%2F with a 404, because a percent-encoded slash could be used to
bypass <Directory> or <Location> restrictions. If your application
legitimately uses %2F in path segments (e.g., Base64-encoded tokens),
enable it:
# Allow %2F in URLs (use with caution)
AllowEncodedSlashes On
# Or decode them to real slashes (even more caution)
AllowEncodedSlashes NoDecode
With NoDecode, the %2F is passed through to the backend without
decoding, so the application sees the literal %2F. With On, httpd
decodes it to / before the application sees it.
Backslashes: On Windows, httpd converts backslashes to forward slashes
automatically. On Unix, a backslash in a URL is technically legal but unusual.
If your URLs contain backslashes, use [B] to ensure they’re properly
encoded:
# Handles paths like /files/path\to\file
RewriteRule ^/files/(.*)$ /download?path=$1 [B,L]
Common mistake: Combining [B] and [NE] on the same rule.
They have opposite effects—[B] encodes backreferences while [NE]
prevents encoding of the output. Use one or the other depending on
whether your problem is under-encoding or over-encoding.
14.6.8. Performance with Large Numbers of Redirects¶
Problem: You have hundreds or thousands of redirects and are concerned about performance impact. The “Redirects and rewrites and performance” thread asks directly: “At what point does it begin to affect performance with the number of redirects?” for a site with ~10,000 accumulated redirects.
Approach: RewriteMap (DBM or text file), database-backed lookups
Individual RewriteRule directives are evaluated sequentially—httpd
tests each regex in order until one matches. Ten rules are fine. A hundred
is manageable. But at 1,000+, each request walks through a long chain of
regex evaluations, and the cost adds up.
Rough guidance:
< 100 redirects: Individual
RedirectorRewriteRuledirectives are fine. No measurable performance impact.100–1,000 redirects: You may notice a few milliseconds of added latency per request. Consider switching to a map.
1,000+ redirects: Use a
RewriteMap. The sequential regex scan becomes the dominant cost of request processing.
Migrating to a RewriteMap:
Convert individual rules to a text map file:
# Before: 5,000 individual rules in httpd.conf
RewriteRule ^/old/page-1$ /new/page-1 [R=301,L]
RewriteRule ^/old/page-2$ /new/page-2 [R=301,L]
# ... 4,998 more ...
# After: a single rule with a map lookup
RewriteMap redirects "txt:/etc/httpd/conf/redirect-map.txt"
RewriteCond ${redirects:$1|NOT_FOUND} !NOT_FOUND
RewriteRule ^/(.*)$ ${redirects:$1} [R=301,L]
The map file:
# redirect-map.txt (one key/value pair per line)
old/page-1 /new/page-1
old/page-2 /new/page-2
For maximum performance, convert the text map to DBM format:
httxt2dbm -i redirect-map.txt -o redirect-map.db
Then reference the DBM map:
RewriteMap redirects "dbm:/etc/httpd/conf/redirect-map.db"
A DBM lookup is a hash table operation—O(1) regardless of whether the map contains 100 or 100,000 entries. The text map is also O(1) after initial load (httpd reads it into a hash table at startup), but the DBM format loads faster and uses less memory for very large maps.
Updating the map: Edit the text file and run httxt2dbm again.
For the text map type, httpd detects file changes and reloads
automatically. For DBM maps, a graceful restart is needed:
httxt2dbm -i redirect-map.txt -o redirect-map.db
apachectl graceful
See Chapter 8 for a detailed treatment of all map types and their performance characteristics.
14.7. When NOT to Use mod_rewrite¶
As discussed throughout this book, mod_rewrite is powerful but often
not the best tool for the job. These recipes show problems that are better
solved with other modules.
14.7.1. Simple Redirects: Use Redirect, Not RewriteRule¶
Problem: You’re using RewriteRule with [R=301] for simple
page-to-page or site-to-site redirects. A mailing list thread titled
“redirect vs. rewrite” directly asks: “What is the difference between
Redirect permanent / and RewriteRule ^/?(.*) [R,L]?”
Approach: Redirect / RedirectMatch from mod_alias
The answer from the mailing list: for simple redirects, they’re
functionally equivalent, but Redirect is clearer, faster (no regex
engine involved for plain Redirect), and less error-prone. As one
respondent notes: “Golden rule: if source ends in trailing slash, target
must also end in trailing slash.”
Task |
|
|
|
|---|---|---|---|
Single page redirect |
Best ✓ |
Works |
Overkill |
Redirect preserving path |
Best ✓ |
Works |
Overkill |
Regex-based redirect |
No |
Best ✓ |
Works |
Conditional redirect (header, IP, etc.) |
No |
No |
Required ✓ |
Redirect with query string manipulation |
No |
No |
Required ✓ |
Examples:
# Single page --- Redirect is simplest
Redirect permanent /old-page.html /new-page.html
# Entire directory --- Redirect preserves path automatically
Redirect permanent /blog/ https://blog.example.com/
# Pattern-based --- RedirectMatch when you need regex
RedirectMatch 301 ^/user/([0-9]+)$ /profile/$1
# Conditional --- RewriteRule only when you need RewriteCond
RewriteEngine On
RewriteCond %{HTTP_HOST} ^old\.example\.com$
RewriteRule ^/(.*)$ https://new.example.com/$1 [R=301,L]
The golden rule of trailing slashes: when using Redirect to
redirect a directory, if the source ends with a trailing slash, the
target must also end with a trailing slash. Otherwise, the path
appending produces mangled URLs:
# CORRECT: both end with /
Redirect permanent /old-section/ /new-section/
# /old-section/page.html -> /new-section/page.html
# WRONG: missing trailing slash on target
Redirect permanent /old-section/ /new-section
# /old-section/page.html -> /new-sectionpage.html (broken!)
Redirect has no regex engine overhead. For a plain path-to-path
redirect, it’s the fastest option. Reaching for RewriteRule when
Redirect would do the job is the most common case of mod_rewrite
overuse in the wild.
14.7.2. Proxying: Use ProxyPass, Not RewriteRule [P]¶
Problem: You’re using RewriteRule with the [P] flag to proxy
requests to a backend server.
Approach: ProxyPass / ProxyPassReverse from mod_proxy
ProxyPass is the right tool for reverse proxying:
ProxyPass "/app/" "http://backend.local:8080/app/"
ProxyPassReverse "/app/" "http://backend.local:8080/app/"
The equivalent mod_rewrite approach:
RewriteEngine On
RewriteRule ^/app/(.*)$ http://backend.local:8080/app/$1 [P]
ProxyPassReverse "/app/" "http://backend.local:8080/app/"
Both proxy the request, but ProxyPass is preferred for several reasons:
Feature |
|
|
|---|---|---|
Connection pooling |
Yes (keeps persistent connections to backend) |
No (new connection per request) |
Load balancing |
Yes ( |
Manual only |
Health checks |
Yes ( |
No |
Error handling |
|
Minimal |
Header rewriting |
|
Still need |
WebSocket support |
Via |
Fragile |
When [P] is actually needed: Use it when you need conditional
proxying that ProxyPass can’t express—for example, proxying only
when a specific cookie is present, or routing to different backends based
on a regex capture:
# Route to different backends based on API version in the URL
RewriteEngine On
RewriteRule ^/api/v1/(.*)$ http://backend-v1:8080/$1 [P]
RewriteRule ^/api/v2/(.*)$ http://backend-v2:8080/$1 [P]
Even here, consider ProxyPass with <Location> blocks first.
[P] should be your last resort, not your first instinct.
14.7.3. Conditional Logic: Use <If> Expressions¶
Problem: You’re using mod_rewrite for conditional configuration
that doesn’t involve URL rewriting—like setting headers based on
request properties or denying access based on complex conditions.
Approach: <If> expressions (available since httpd 2.4)
The <If> directive supports a rich expression language that can test
request headers, environment variables, IP addresses, and more—without
the cognitive overhead of RewriteCond/RewriteRule syntax.
Here are common RewriteCond patterns and their <If> equivalents.
Blocking by User-Agent:
# mod_rewrite approach
RewriteEngine On
RewriteCond %{HTTP_USER_AGENT} (BadBot|EvilScraper) [NC]
RewriteRule ^ - [F]
# <If> approach --- clearer intent
<If "%{HTTP_USER_AGENT} =~ /BadBot|EvilScraper/i">
Require all denied
</If>
Checking a request header:
# mod_rewrite: set header based on another header
RewriteCond %{HTTP:X-Forwarded-Proto} =https
RewriteRule ^ - [E=PROTO:https]
# <If> approach
<If "req('X-Forwarded-Proto') == 'https'">
Header set Strict-Transport-Security "max-age=31536000"
</If>
Checking source IP:
# mod_rewrite --- awkward regex on IP
RewriteCond %{REMOTE_ADDR} !^10\.
RewriteRule ^/admin - [F]
# <If> --- proper CIDR matching
<If "! %{REMOTE_ADDR} -ipmatch '10.0.0.0/8'">
<Location "/admin">
Require all denied
</Location>
</If>
Combining multiple conditions (AND / OR):
# mod_rewrite: conditions are implicitly AND
RewriteCond %{REMOTE_ADDR} !^10\.
RewriteCond %{HTTP_USER_AGENT} !InternalMonitor
RewriteRule ^/status - [F]
# <If>: explicit boolean operators
<If "! %{REMOTE_ADDR} -ipmatch '10.0.0.0/8' && \
%{HTTP_USER_AGENT} !~ /InternalMonitor/">
<Location "/status">
Require all denied
</Location>
</If>
The <If> approach is preferred when you’re not actually rewriting the
URL—you’re just making access control or header decisions. The expression
syntax is documented in the Apache Expressions reference.
14.7.4. Fallback Resources: Use FallbackResource, Not RewriteRule¶
Problem: You want all requests for non-existent files to be handled
by a single script (the front controller pattern). The traditional approach
uses mod_rewrite, but FallbackResource does this in a single line.
Approach: FallbackResource directive
FallbackResource /index.php
This single line replaces the entire WordPress-style rewrite block for the common case. It was added in httpd 2.2.16 specifically to address this extremely common use case.
The traditional mod_rewrite front controller block looks like this:
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
FallbackResource replaces all four lines with one:
FallbackResource /index.php
Both achieve the same result: if the requested file or directory doesn’t
exist, serve /index.php instead. The FallbackResource version is
faster (no regex evaluation), clearer, and immune to the .htaccess
re-processing loop that trips up so many users.
To disable FallbackResource in a subdirectory (e.g., an /admin
area that should show real 404s):
<Directory "/var/www/html/admin">
FallbackResource disabled
</Directory>
Limitations of FallbackResource:
Cannot apply conditions (e.g., “only for certain file extensions”)
Cannot route to different scripts based on URL pattern
Cannot rewrite the URL—it always serves the fallback as-is
Does not set
PATH_INFOthe waymod_rewritedoes
If you need conditional routing or multiple front controllers, you still
need mod_rewrite. But for the overwhelmingly common case of a single
front controller (WordPress, Laravel, Symfony, Drupal), FallbackResource
is the right tool.
14.7.5. Advanced: Using RewriteMap for Dynamic Rewrites¶
Problem: You need dynamic URL mapping that’s too complex for static rules—looking up redirects in a database, calling an external program, or using complex logic. Threads on “Perl prg RewriteMap always returns blank” and “RewriteMap prg: How to pass value from Python3 script back to Apache24?” show users struggling with the external program map type.
Approach: RewriteMap with various map types (txt, dbm,
prg, dbd, int)
RewriteMap defines a named mapping function that can be called from
RewriteRule and RewriteCond substitutions. It must be defined in
server config (not .htaccess), but the maps it creates can be used
anywhere. See Chapter 8 for the full treatment.
Text file map (``txt``) — simple key/value pairs:
# Define the map (server config only)
RewriteMap redirects "txt:/etc/httpd/conf/redirect-map.txt"
# Use it
RewriteEngine On
RewriteCond ${redirects:$1} !=""
RewriteRule ^/(.*)$ ${redirects:$1} [R=301,L]
The map file is a plain text file with one key/value pair per line:
# redirect-map.txt
old-page.html /new-section/updated-page.html
products/legacy /catalog/current
DBM map (``dbm``) — hashed lookup, O(1) performance:
RewriteMap redirects "dbm:/etc/httpd/conf/redirect-map.db"
Convert a text map to DBM format using httxt2dbm:
httxt2dbm -i redirect-map.txt -o redirect-map.db
This is the right choice for maps with thousands of entries. See the performance recipe later in this chapter.
External program map (``prg``) — call an external script:
RewriteMap mymap "prg:/usr/local/bin/rewrite-lookup.py"
RewriteRule ^/user/(.+)$ ${mymap:$1} [L]
The program receives lookup keys on stdin (one per line) and must return results on stdout (one per line). Critical pitfalls:
Buffering: stdout must be line-buffered or unbuffered. In Python, use
print(..., flush=True)or run withPYTHONUNBUFFERED=1. In Perl, set$| = 1;. If the program buffers its output, httpd will hang waiting for a response.Persistence: the program starts once and runs for the lifetime of the httpd process. It must loop forever reading stdin.
Crashes: if the program exits, all subsequent lookups return empty strings with no error in the log. Check the error log for startup failures.
A minimal Python example:
#!/usr/bin/env python3
import sys
# Must flush every line --- httpd is waiting for the response
for line in sys.stdin:
key = line.strip()
# Your lookup logic here
result = lookup_user(key)
print(result or "NOT_FOUND", flush=True)
Database map (``dbd``) — SQL lookup via mod_dbd:
# Requires mod_dbd to be configured with a database connection
RewriteMap mymap "dbd:SELECT target FROM redirects WHERE source = %s"
RewriteRule ^/(.*)$ ${mymap:$1} [R=301,L]
This queries the database for every request that matches the rule.
Use connection pooling (DBDMin, DBDKeep, DBDMax) to
manage database load.
Built-in functions (``int``) — string transformations:
RewriteMap lowercase "int:tolower"
RewriteRule ^/(.*)$ /${lowercase:$1} [R=301,L]
Available functions: tolower, toupper, escape, unescape.
14.7.6. Advanced: IP Range Matching with RewriteMap¶
Problem: You need to match client IPs against CIDR ranges in rewrite rules. A thread on “Apache rewritemap condition that will CIDR-ipmatch against returned value from the map?” shows this is not straightforward with standard rewrite conditions.
Approach: RewriteMap with prg: type for CIDR matching, or
<If> with -ipmatch operator (preferred for simple cases)
The simplest approach uses the <If> directive with the -ipmatch
operator, available since httpd 2.4:
# Block access from a specific CIDR range
<If "%{REMOTE_ADDR} -ipmatch '192.168.0.0/16'">
Require all denied
</If>
# Redirect internal users to an intranet version
<If "%{REMOTE_ADDR} -ipmatch '10.0.0.0/8'">
RedirectMatch ^/portal(.*)$ https://intranet.example.com$1
</If>
You can combine multiple ranges:
<If "%{REMOTE_ADDR} -ipmatch '10.0.0.0/8' || %{REMOTE_ADDR} -ipmatch '172.16.0.0/12'">
# Allow access for both RFC 1918 ranges
Require all granted
</If>
For dynamic CIDR lookups—where the ranges change frequently or are
stored externally—use a RewriteMap with an external program:
# In server config (not .htaccess)
RewriteMap cidrcheck "prg:/usr/local/bin/cidr-check.py"
RewriteEngine On
RewriteCond ${cidrcheck:%{REMOTE_ADDR}} =blocked
RewriteRule ^ - [F]
The external program reads an IP address on stdin and returns blocked
or allowed on stdout, checking it against a list of CIDR ranges:
#!/usr/bin/env python3
import sys
import ipaddress
BLOCKED_RANGES = [
ipaddress.ip_network('198.51.100.0/24'),
ipaddress.ip_network('203.0.113.0/24'),
]
# Line-buffered output is critical
for line in sys.stdin:
ip = line.strip()
try:
addr = ipaddress.ip_address(ip)
if any(addr in net for net in BLOCKED_RANGES):
print("blocked", flush=True)
else:
print("allowed", flush=True)
except ValueError:
print("allowed", flush=True)
Recommendation: For static CIDR ranges, <If> -ipmatch is
dramatically simpler and performs better. Use the RewriteMap approach
only when the ranges must be loaded from an external source or change
without restarting httpd.
See Chapter 8 for more on external program maps and their buffering requirements.