An Apache site whose .htaccess routes everything to a front controller starts returning 500s, and the error log reports that the request exceeded the limit of 10 internal redirects. What causes that loop, and how do you write the rule so it cannot happen?
answer
- the rewrite is fed back through mapping
- L ends the pass, not the request
- the rule matches its own output
- condition must stop being true
- default ceiling is ten
basics
~20 sA per-directory rewrite is re-injected into the request-mapping cycle, so the .htaccess runs again against its own output. If the rule matches the rewritten URL too, it loops until httpd hits LimitInternalRecursion and returns 500. Guard the rule so it cannot match its result.
solid answer
~50 sIn per-directory context mod_rewrite does not simply hand the substitution to the file mapper. It restarts the request-mapping cycle with the new URI, which means the same `.htaccess` — and the same rules — run again. `[L]` only ends the current pass, so a rule like `RewriteRule ^ index.php [L]` matches its own output on the next pass and recurses. httpd counts internal redirects and aborts at `LimitInternalRecursion`, default 10, logging the message you quoted and returning 500. The standard fix is a guard that stops being true after the rewrite: `RewriteCond %{REQUEST_FILENAME} !-f` and `!-d` before the rule, so once the URI maps to the real `index.php` file the condition fails and the loop ends. An explicit `RewriteCond %{REQUEST_URI} !^/index\.php$` does the same job. `[END]` (httpd 2.3.9 and later) is the blunter instrument: it stops rewriting for good rather than merely ending the pass.
code
apache · 10 lines# Loops: the substitution still matches ^ on the next pass.
# RewriteEngine On
# RewriteRule ^ index.php [L]
# Terminates: after one internal redirect the URI maps to a real file,
# so !-f is false and the rule no longer fires.
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]go deeper
Know that a rewrite rule which still matches its own result will loop, and that the standard front-controller guard is a check for whether a real file or directory exists.
Explain the mechanism: a per-directory rewrite restarts request mapping, so the ruleset sees its own output, and [L] only ends the current pass rather than the request.
Demonstrate the diagnosis — error log rather than devtools, rewrite trace to find which rule fires on the second pass — and reject raising LimitInternalRecursion as a fix.
Make the guard a house convention and push front-controller rules into reviewed server config, so this class of failure is caught by a config test rather than by production 500s.
## Why an internal rewrite comes back around A rewrite in the server configuration happens during URL-to-filename translation and is essentially done once it produces a path. A rewrite in per-directory context cannot work that way, because the substitution may map into a *different* directory — one with its own `.htaccess`, its own access control, its own rules. httpd's answer is to treat the result as an **internal redirect**: it discards the current mapping and re-runs the whole mapping phase with the new URI. That re-run includes reading and applying `.htaccess` again — including the file that just performed the rewrite. So the ruleset is offered its own output. `[L]` does not prevent this. "Last" means "stop evaluating further rules *in this pass*". It is a within-ruleset control, not a within-request one. This is the single most common misunderstanding in the topic, and it is why adding more `[L]` flags to a looping config never helps. ## The classic looping rule ```apache # /var/www/html/.htaccess — loops RewriteEngine On RewriteRule ^ index.php [L] ``` Pass 1: URI `/anything` → `/index.php`. Internal redirect. Pass 2: URI `/index.php` still matches `^` → `/index.php`. Internal redirect. Pass 3, 4, 5… identical. httpd increments a recursion counter on each internal redirect and compares it with `LimitInternalRecursion`, whose default is 10. When the count is exceeded it aborts the request with 500 and logs that the request exceeded the limit of 10 internal redirects due to a probable configuration error. Two details matter for diagnosis. First, the client sees a plain 500 — nothing about the loop travels on the wire, unlike a browser-visible redirect loop, so this is an error-log problem, not a devtools problem. Second, raising `LimitInternalRecursion` does not fix anything; it makes the server do ten times the work before returning the same 500. Treat the limit as a smoke detector, not a dial. ## Guards that actually terminate The rule must stop matching once it has done its job. The idiomatic form tests the mapped filename: ```apache RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [L] ``` On the first pass `/anything` maps to a path with no file behind it, both conditions hold, and the rewrite happens. On the second pass `%{REQUEST_FILENAME}` is the real `/var/www/html/index.php`, the `-f` test succeeds, `!-f` fails, and the rule does not fire. The recursion terminates after exactly one internal redirect. As a bonus, the same guard is what stops the front controller from swallowing requests for real static assets. An explicit exclusion works too and is clearer when the target is not a file test: ```apache RewriteCond %{REQUEST_URI} !^/index\.php$ RewriteRule ^ index.php [L] ``` And since httpd 2.3.9 there is `[END]`: ```apache RewriteRule ^ index.php [END] ``` `[END]` stops the current pass *and* prevents any further per-directory rewriting of this request in later passes. It is the direct answer to "I want L to mean what everybody thinks L means". Use it deliberately, though: it also stops rules in *other* directories from applying, which is occasionally load-bearing. ## Loops that are not this loop Several neighbouring failures produce similar-looking symptoms and want different fixes: - **A browser-visible redirect loop.** If the rule carries `[R]`, the recursion happens in the client instead: the browser reports too many redirects, and each hop is visible in devtools or `curl -IL`. Usual causes are a rule that redirects to a URL still matching its own pattern, or two layers — an edge proxy and Apache — each trying to canonicalise scheme or trailing slash in opposite directions. - **Rules resurrected by inheritance.** A parent `.htaccess` plus `RewriteOptions InheritBefore` in a child can reintroduce a rule you thought you had scoped away, producing a loop only in one subtree. - **A rewrite target that is itself rewritten elsewhere.** Directory A rewrites into directory B, whose own `.htaccess` rewrites back. Each hop is legitimate in isolation; the cycle only exists across files, which is exactly what makes it hard to see by reading one of them. ## Debugging procedure Raise the error log level to a trace level — httpd 2.4 dropped the old `RewriteLog` directive in favour of `LogLevel` — and replay one request. The trace prints each pass, the string matched, the conditions evaluated and the substitution produced, so a loop appears as the same substitution repeating with an incrementing internal-redirect count. That transcript tells you which rule fires on the *second* pass, which is the rule that needs the guard. Reading the config alone rarely does, because the bug is not in any single rule — it is in the rule's relationship to its own output. Finally, prevention: put the `-f`/`-d` guards on every front-controller rule as a matter of house style, and prefer hoisting the ruleset into a `<Directory>` block in server config, where a mistake is caught by a config test before it ever reaches a request.
- Why does raising LimitInternalRecursion not fix the problem?Because the loop has no exit condition. The limit is only the point at which httpd gives up; raising it to 100 means each affected request performs 100 internal redirects, re-reading and re-parsing the .htaccess files each time, before returning the same 500. You have turned a fast failure into a slow one and multiplied the CPU cost of every hit. The directive exists for genuinely deep-but-finite mappings, not as a loop remedy.
- How does this failure differ from a browser reporting too many redirects?Position and visibility. Internal redirects happen entirely inside httpd, so the client sees one request and one 500, and the evidence lives only in the error log. A browser redirect loop means the rule carried `[R]`, so each hop is a real 3xx on the wire, visible in devtools or `curl -IL`, and the browser rather than the server enforces the cut-off. Same shape of bug, completely different diagnostic path.
- What does the [END] flag do that [L] does not?`[L]` ends the current pass of the ruleset; the substitution is still re-injected into request mapping, so per-directory rules — including the same file's — run again. `[END]`, available since httpd 2.3.9, terminates the rewrite process for the request outright: no further per-directory rewriting happens at all. It is the cleanest way to break a self-matching rule, at the cost of also suppressing legitimate rules in directories the request would subsequently reach.
- Two directories each rewrite into the other and requests 500. How do you find that?The rewrite trace, because no single file contains the cycle. Raise `LogLevel` to a trace level and replay one request: the transcript shows each internal redirect with the directory context it was evaluated in, so you see A→B→A directly. Then decide which hop is redundant. Cross-directory cycles are a strong argument for hoisting the whole ruleset into one `<Directory>` block where the flow is readable in one place.
saying these in an interview costs you the question
- Adds another [L] and expects the loop to stop
- Raises LimitInternalRecursion to make the 500 go away
- Says the browser is causing the redirect loop
- Assumes internal redirects appear in devtools
- Believes the front controller itself is crashing