mod_rewrite explained: RewriteRule, RewriteCond and flags

Updated November 2026

mod_rewrite changes request URLs with regular expressions: it can map a clean address like /products/42 to a script, redirect old URLs, or refuse requests. It is powerful and easy to get wrong, so this guide explains how a rule is evaluated, the flags you actually need, the differences between .htaccess and the server configuration, and how to see what a rule is doing.

Try something simpler first. The Apache documentation calls mod_rewrite a last resort. Plain redirects are better done with Redirect (Redirects), front-controller apps with FallbackResource, folders outside the document root with Alias, and header checks with <If>. Use mod_rewrite when you need conditions those cannot express.

Turn it on

On Debian and Ubuntu the module is not enabled by default:

sudo a2enmod rewrite
sudo apache2ctl configtest && sudo systemctl reload apache2

On RHEL, Alma, Rocky and Fedora, mod_rewrite is already loaded by /etc/httpd/conf.modules.d/00-base.conf; after changes run sudo apachectl configtest && sudo systemctl reload httpd. Check with apachectl -M | grep rewrite, which should print rewrite_module (shared).

Every scope that uses rules needs its own RewriteEngine On. Rules in the main server are not inherited by virtual hosts unless you add RewriteOptions Inherit in the vhost.

How a rule is read

RewriteCond  TestString  CondPattern  [flags]
RewriteRule  Pattern     Substitution [flags]
  1. Apache takes the URL path (no host name, no query string) and tests it against the RewriteRule pattern.
  2. Only if the pattern matches does it check the RewriteCond lines directly above that rule. They are joined with AND unless you add [OR].
  3. If everything matches, the path is replaced by the substitution. A substitution of - means "leave the URL alone", used when you only want a flag such as [F].
  4. Processing continues with the next rule, working on the new URL, until a flag such as L, END or F stops it.

Backreferences: $1…$9 are groups from the RewriteRule pattern, %1…%9 are groups from the last matching RewriteCond. A pattern starting with ! (negated) never sets backreferences.

A RewriteCond can test variables such as %{HTTP_HOST}, %{HTTPS} (on or off), %{REQUEST_URI}, %{QUERY_STRING}, %{HTTP_REFERER}, %{REMOTE_ADDR}, any request header with %{HTTP:Header-Name}, and the file system with -f (is a file) and -d (is a directory).

A first rule: clean URLs

Serve /products/42 from /product.php?id=42, keeping any query string the visitor added. In .htaccess in the document root:

RewriteEngine On
RewriteRule ^products/([0-9]+)/?$ product.php?id=$1 [QSA,END]

The same rule in a virtual host, where the path still starts with a slash:

<VirtualHost *:443>
    ServerName www.example.com
    DocumentRoot /var/www/example.com/public
    RewriteEngine On
    RewriteRule ^/products/([0-9]+)/?$ /product.php?id=$1 [QSA,END]
</VirtualHost>

A request for /products/42?color=red reaches PHP as id=42&color=red. Without QSA the visitor's color=red would be dropped, because a substitution that contains ? replaces the original query string.

.htaccess vs server config

Server / virtual host.htaccess or <Directory>
Pattern is matched against/products/42 (leading slash)products/42 (directory prefix removed, never a leading slash)
Pattern ^/WorksNever matches
%{REQUEST_FILENAME} with -fNot yet mapped; use %{DOCUMENT_ROOT}%{REQUEST_URI}The real file path
RequirementsNone beyond the moduleAllowOverride FileInfo and Options FollowSymLinks or SymLinksIfOwnerMatch
SpeedRegexes compiled once at startupFile read and regexes compiled on every request

A pattern written as ^/?products/… works in both places. If FollowSymLinks is off in a directory, every request there gets 403 and the error log says AH00670: Options FollowSymLinks and SymLinksIfOwnerMatch are both off. Relative substitutions in a subdirectory may also need RewriteBase /subdir/ so Apache knows which URL prefix to put back. See .htaccess files for when per-directory files make sense.

The flags you will use

[L] last
Stop processing this round of rules. In the server config that is the end. In .htaccess, a rewritten URL is processed again from the top, which causes loops (see below).
[END]
Stop, and do not run the rules again after an internal rewrite in per-directory context. The safe choice in .htaccess for internal rewrites.
[R=301] redirect
Send the browser to the new URL. Plain [R] is a 302 (temporary). Codes outside 300–399 drop the substitution and just return that status, so [R=503] sends 503 Service Unavailable. Write [R=301,L] so later rules do not touch the redirect.
[NC] no case
Case-insensitive match. On a RewriteCond it applies to that condition only.
[QSA] query string append
Merge the original query string with the one in the substitution. [QSD] discards it instead.
[F] and [G]
Return 403 Forbidden or 410 Gone. Both imply L.
[NE], [PT], [P]
Do not escape special characters in a redirect; pass the result back through Alias/Redirect mapping; hand the request to mod_proxy. Never build a [P] target from user input: that allows server-side request forgery.

Two newer flags. Since the 2024 fixes for CVE-2024-38474 and CVE-2024-38475, httpd refuses some substitutions unless you opt in: UnsafeAllow3F (an encoded %3F in the request combined with a ? in the substitution; such requests otherwise get 403 Forbidden) and UnsafePrefixStat (a server-scoped substitution that starts with a variable or backreference and resolves to a file path). Both flags exist since httpd 2.4.60. If old rules started failing after an update, read the error log before adding either flag, and add it only when users cannot steer the result.

More examples

Serve .html files without the extension

RewriteEngine On
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.+?)/?$ $1.html [END]

/about now serves about.html; missing pages still return 404.

Maintenance mode with 503

ErrorDocument 503 /maintenance.html
RewriteEngine On
RewriteCond %{DOCUMENT_ROOT}/maintenance.flag -f
RewriteCond %{REQUEST_URI} !=/maintenance.html
RewriteRule ^ - [R=503,L]

touch maintenance.flag in the document root switches every page to the maintenance notice with status 503 Service Unavailable; delete the file to go back. The != condition is a plain string comparison, not a regex.

Front controller

Many PHP frameworks ship this:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [END]

One line does the same job, in .htaccess or the server config: FallbackResource /index.php. The application reads the original path from REQUEST_URI.

Redirect recipes (HTTPS, www, old URLs, long redirect lists with RewriteMap) are on Redirects; Referer checks for images are on Hotlink protection.

Debugging rules

RewriteLog was removed in 2.4. Turn on mod_rewrite's trace messages in the error log instead, in the virtual host or main config (not .htaccess):

LogLevel warn rewrite:trace3

Reload, make one request, and filter the log (/var/log/apache2/error.log or /var/log/httpd/error_log):

sudo grep -F '[rewrite:' /var/log/apache2/error.log | tail -20

For the clean-URL rule above, the interesting lines read like this (prefixes shortened):

[perdir /var/www/html/] strip per-dir prefix: /var/www/html/products/42 -> products/42
[perdir /var/www/html/] applying pattern '^products/([0-9]+)/?$' to uri 'products/42'
[perdir /var/www/html/] rewrite 'products/42' -> 'product.php?id=42'
split uri=/var/www/html/product.php?id=42 -> uri=/var/www/html/product.php, args=id=42&color=red
[perdir /var/www/html/] internal redirect with /product.php [INTERNAL REDIRECT]

You can see exactly which string the pattern was applied to, which is the usual cause of "my rule never matches". Levels go up to trace8, but anything above trace2 slows the server dramatically, so switch it back to LogLevel warn when done. For quick checks from outside, curl -sI https://www.example.com/old shows the status and Location header.

Common mistakes

Sources