How mod_rewrite Actually Works in .htaccess

RewriteCond binds to the next RewriteRule only, the leading slash is stripped in .htaccess, and [L] doesn't mean what you think — the mechanics, correctly.

Published 2026-10-05

.htaccess is not a config file Apache reads once — it is re-read on every request that touches its directory. That single fact explains most of the weirdness: there is no apachectl configtest for it (it’s evaluated per-request), a typo takes effect instantly (no restart needed — and no warning), and performance-wise it is the “expensive” way to configure Apache (a VirtualHost <Directory> block is the cheap equivalent).

Anatomy of a rule

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</IfModule>

Four moving parts:

  1. <IfModule mod_rewrite.c> — guard. If the module isn’t loaded, everything inside is skipped instead of erroring the request into a 500. Every snippet you paste should be guarded this way.
  2. RewriteEngine On — required. Rewrite directives are ignored without it. It must appear in each <IfModule> block that contains rules (repeating it is harmless).
  3. RewriteCond teststring condpattern — a condition that binds only to the next RewriteRule. Two consecutive conds are AND-ed; add [OR] to the first to make an either/or. Conds never bind to the second-next rule — a blank line doesn’t detach them, but another RewriteRule does.
  4. RewriteRule pattern substitution [flags] — if the pattern matches (and its conds pass), the URL-path is replaced by the substitution.

The leading-slash trap

In server config (httpd.conf, VirtualHost), a RewriteRule pattern sees the full URL-path with the leading slash: ^/admin. Inside .htaccess, Apache first strips the directory prefix — a file in the document root sees admin, not /admin. So:

  • ^/admin in .htaccess never matches anything — the most common beginner bug.
  • ^(.*)$ captures the path relative to the .htaccess directory — for a root-level file that’s the full path minus /.
  • %{REQUEST_URI} is unaffected — it always holds the original full path, which is why generators prefer it in substitutions (https://%{HTTP_HOST}%{REQUEST_URI}) over $1.

RewriteBase / exists to repair the relative-path mapping when an .htaccess lives in a subdirectory or an alias — needed only for internal rewrites that produce relative substitutions; for external redirects it’s irrelevant.

The condition vocabulary

RewriteCond takes a test string (usually a server variable) and a pattern or operator:

Cond Meaning
%{HTTPS} off request arrived over plain HTTP
%{HTTP_HOST} ^example\.com$ Host header is exactly the apex domain
%{HTTP_REFERER} !^$ Referer header is not empty (! negates)
%{REQUEST_FILENAME} !-f path does NOT resolve to a real file
%{REQUEST_FILENAME} !-d path does NOT resolve to a real directory
%{HTTP:X-Forwarded-Proto} !https proxy says the client scheme wasn’t HTTPS

Notes that matter: ! before the pattern negates the match — it does not “else” the rule. -f/-d/-s are file tests, not regexes. [NC] on a cond makes the match case-insensitive — almost always wanted for %{HTTP_HOST}.

Flags that change everything

Flag Effect
[L] Last rule for this round. In per-dir context the rewritten request can re-enter processing — [L] stops the current pass, not all passes.
[END] Apache 2.4’s hard stop — no re-entry. What people usually think [L] does.
[R=301] External redirect (302 default). Always pair with L — otherwise Apache keeps rewriting a URL it already sent the client away to.
[F] Forbidden — immediate 403, no substitution needed (RewriteRule ^ - [F]).
[NC] Case-insensitive pattern match (.JPG vs .jpg).
[QSA] Merge the incoming query string onto a substitution that adds its own. Without it, ?x=1 in the subst replaces the original query.
[E=name:value] Set an environment variable — how the WordPress block forwards HTTP_AUTHORIZATION.

The L-vs-END distinction bites in .htaccess specifically: a rewrite to a path that also matches another rule can loop the pass. If you ever see a 500 from “too many internal redirects,” [END] is the fix — or the [L] flag you thought was stopping things wasn’t.

What runs before what

Rewrite rules execute top to bottom, first match wins. Two consequences:

  • Put exits early — the canonical-host/HTTPS redirect and any [F] blocks belong above path-specific rules, or a redirect to /index.php front controller fires first and the 301 never happens.
  • Module order beats file order: in .htaccess, mod_rewrite directives always run before mod_alias ones (Redirect, RedirectMatch), no matter how you sequence them in the file. Mixing the two is legal — just know the rewrite engine gets the first shot at every request.

Debugging when it doesn’t work

LogLevel rewrite:trace3 is the tool — but it only lives in server config, not .htaccess, so on shared hosting you’re locked out of it. The practical ladder: reproduce on a local Apache or staging where you own the config; bisect the file by commenting sections; check that mod_rewrite is even loaded (a request to a guarded block silently does nothing when the module is absent — that’s the trade-off of <IfModule>); and remember the browser caches 301s hard, so a “fixed” rule may still appear broken because Chrome is replaying yesterday’s redirect. Test redirects with curl -I, not a warm browser.

For the redirects this knowledge plugs into, see the 301 redirects guide; for the failure modes, the 500-error checklist.