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.
.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:
<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.RewriteEngine On— required. Rewrite directives are ignored without it. It must appear in each<IfModule>block that contains rules (repeating it is harmless).RewriteCond teststring condpattern— a condition that binds only to the nextRewriteRule. 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 anotherRewriteRuledoes.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:
^/adminin .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 aredirect to /index.phpfront controller fires first and the 301 never happens. - Module order beats file order: in .htaccess,
mod_rewritedirectives always run beforemod_aliasones (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.