troubleshooting2025-03-01Β·8Β·161/348

Apache .htaccess Rewrite Configuration: Fixing Infinite Redirect Loops

Debugging Apache mod_rewrite rules in .htaccess that cause infinite 301 redirect loops, with detailed analysis of regex patterns and server variables.

Introduction

Apache's .htaccess file is one of those things that seems simple until it is not. I have been managing Apache servers on and off for years, but every now and then a rewrite rule humbles me. Recently, I was migrating a PHP application from one domain structure to another and needed to implement a set of URL rewrites. The goal was straightforward: redirect all traffic from /old-section/* to /new-section/*, and rewrite internal requests so that /new-section/page internally maps to /page.php in the document root.

Instead of a clean migration, I ended up with an infinite redirect loop that crashed my browser tab. Chrome eventually displayed the classic "ERR_TOO_MANY_REDIRECTS" error. After several hours of trial and error (and reading the Apache mod_rewrite documentation more carefully than I ever had before), I found the problem: my RewriteRule was matching its own output.

Environment

  • Apache version: 2.4.57 (Ubuntu 22.04)
  • mod_rewrite: Enabled
  • Document root: /var/www/myapp/public
  • PHP version: 8.2
  • Operating system: Ubuntu 22.04 LTS

The application was a Laravel-based PHP app that used clean URLs. The migration involved restructuring the URL paths from /old-section/dashboard to /new-section/dashboard, while keeping the underlying PHP routing intact.

Problem

The initial .htaccess file contained the following rewrite rules:

RewriteEngine On

# Redirect old URLs to new URLs
RewriteRule ^old-section/(.*)$ /new-section/$1 [R=301,L]

# Rewrite new URLs to PHP files
RewriteRule ^new-section/(.*)$ /$1 [L]

# Handle root requests
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]

When I accessed /old-section/dashboard in the browser, the following sequence occurred:

  1. Request: GET /old-section/dashboard
  2. Rule 1 matches, redirects to /new-section/dashboard (301)
  3. Request: GET /new-section/dashboard
  4. Rule 2 matches, rewrites to /dashboard
  5. But /dashboard does not exist as a file, so Apache serves index.php
  6. Laravel processes the request and returns a page

This should have worked. But what actually happened was:

[Fri Mar 01 10:15:23.456789 2025] [core:trace3] [pid 12345] mod_rewrite.c(483): [client 10.0.0.1:54321] 127.0.0.1:443 old-section/dashboard -> /new-section/dashboard [RC=301]
[Fri Mar 01 10:15:23.457890 2025] [core:trace3] [pid 12345] mod_rewrite.c(483): [client 10.0.0.1:54321] 127.0.0.1:443 new-section/dashboard -> /new-section/dashboard [RC=301]
[Fri Mar 01 10:15:23.458901 2025] [core:trace3] [pid 12345] mod_rewrite.c(483): [client 10.0.0.1:54321] 127.0.0.1:443 new-section/dashboard -> /new-section/dashboard [RC=301]

The trace log showed that Rule 2 was redirecting /new-section/dashboard back to /new-section/dashboard β€” an infinite loop. Rule 2 was somehow triggering Rule 1 again.

Analysis

The issue was subtle and took me a while to understand. Apache mod_rewrite processes rules in order, but when a rule with the [L] flag matches and rewrites the URL, Apache restarts rule processing from the top for the new URL. The critical detail is that Rule 2 (^new-section/(.*)$ /$1) was rewriting /new-section/dashboard to /dashboard, but the $1 capture was including the new-section/ prefix because of how the regex was structured.

Wait, that is not right either. Let me re-examine. The regex ^new-section/(.*)$ captures everything after new-section/, so $1 should be dashboard. The rewrite target was /$1, which becomes /dashboard. But the trace showed the redirect was going back to /new-section/dashboard.

The real issue was that the [R=301,L] in Rule 1 was causing the browser to cache the redirect. But more importantly, I had RewriteBase missing. Without RewriteBase, Apache was resolving the rewrite target relative to the current directory, and the redirect was being applied to the rewritten URL again.

I verified this by enabling more verbose logging:

LogLevel alert rewrite:trace6

The trace output revealed that without RewriteBase, the rewrite in Rule 2 was being applied to the full URL path, and the [L] flag was causing a restart of rule processing. On the second pass, the URL still matched the pattern, creating the loop.

Solution

The fix involved three changes to the .htaccess file:

RewriteEngine On
RewriteBase /

# Redirect old URLs to new URLs
RewriteRule ^old-section/(.*)$ /new-section/$1 [R=301,L]

# Rewrite new URLs to PHP files (internal rewrite only)
RewriteRule ^new-section/(.*)$ /$1 [L]

# Handle root requests
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]

But this alone was not enough. I also needed to prevent Rule 1 from matching on subsequent passes. The key insight was that after a 301 redirect, the browser sends a new request, and that request starts rule processing from the top again. The 301 redirect from Rule 1 sends the browser to /new-section/dashboard, and on that new request, Rule 1 should NOT match again because the URL does not start with old-section/.

The actual root cause was that I had a caching issue β€” the browser was caching the original 301 redirect incorrectly. After adding Header always unset Cache-Control and testing with curl instead of a browser, the rules worked correctly:

curl -v -L http://localhost/old-section/dashboard 2>&1 | head -30
# Output:
# < HTTP/1.1 301 Moved Permanently
# < Location: http://localhost/new-section/dashboard
# ...
# < HTTP/1.1 200 OK
# (Content from the PHP application)

The final working .htaccess with all safety measures:

RewriteEngine On
RewriteBase /

# Prevent redirect loops by checking if already rewritten
RewriteCond %{ENV:REDIRECT_STATUS} ^$
RewriteRule ^old-section/(.*)$ /new-section/$1 [R=301,L]

# Rewrite new URLs to PHP files
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^new-section/(.*)$ /$1 [L]

# Handle root requests
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]

The RewriteCond %{ENV:REDIRECT_STATUS} ^$ check ensures that the redirect rule only fires on the initial request, not after an internal rewrite has already been processed. This is a common pattern for preventing loops in complex rewrite configurations.

Lessons Learned

  1. Always test with curl, not just browsers β€” Browsers aggressively cache 301 redirects, which can make debugging redirect loops extremely confusing. Use curl -v to see the actual redirect chain.
  2. Use rewrite trace logging β€” LogLevel alert rewrite:trace6 in your Apache config gives you detailed information about which rules match and what URLs they produce.
  3. Understand the [L] flag β€” The [L] flag stops rule processing for the current pass, but if the URL was rewritten, Apache starts processing from the top again with the new URL. This is the most common source of redirect loops.
  4. Add RewriteBase β€” Even if you think you do not need it, adding RewriteBase / at the top of your .htaccess prevents unexpected path resolution behavior.
  5. Check REDIRECT_STATUS β€” Using %{ENV:REDIRECT_STATUS} to distinguish between initial requests and internally rewritten requests is a powerful technique for preventing loops.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.