DEV Community

linkbruno.com
linkbruno.com

Posted on

ERR_TOO_MANY_REDIRECTS: the 6 configs that cause redirect loops and how to find them

Someone switched the CDN's SSL mode on a Friday afternoon. The CDN started talking to the origin over plain HTTP, the origin redirected every HTTP request to HTTPS, the CDN followed it over HTTP again, and every page on the site became ERR_TOO_MANY_REDIRECTS. The fix was one dropdown. Finding it took two hours.

Loops almost never come from one bad rule. They come from two layers that each think they're doing the right thing.

TL;DR

  • A loop is A redirecting to B redirecting back to A (sometimes through more steps).
  • The usual cause: a proxy/CDN and the origin disagree about scheme, host or trailing slash.
  • Trace it with curl -sIL --max-redirs 10 and look for the repeat.
  • Fix it by deciding which single layer owns each canonical rule.
  • My opinion: HTTPS and www canonicalisation should live in exactly one layer. Two layers doing it "for safety" is how loops are born.

Step 1: see the loop

Browsers just give up. curl shows you the path:

curl -sIL --max-redirs 10 https://example.com/ 2>&1 | grep -iE '^(HTTP|location)|maximum'
Enter fullscreen mode Exit fullscreen mode
HTTP/2 301
location: https://www.example.com/
HTTP/2 301
location: https://example.com/
HTTP/2 301
location: https://www.example.com/
...
curl: (47) Maximum (10) redirects followed
Enter fullscreen mode Exit fullscreen mode

Two URLs alternating. That's a host disagreement: one layer wants www, another wants the bare domain. If you want to scan many URLs, the hop-by-hop script in the redirect chains post flags loops automatically.

Cause 1: proxy terminates TLS, origin forces HTTPS

The CDN or load balancer handles HTTPS and talks to the app over HTTP. The app sees http://, redirects to https://, and the cycle repeats.

Fix: make the origin trust the forwarded scheme.

# behind a load balancer that sets X-Forwarded-Proto
if ($http_x_forwarded_proto = "http") {
    return 301 https://$host$request_uri;
}
Enter fullscreen mode Exit fullscreen mode

In frameworks, enable "trust proxy" (Express: app.set('trust proxy', 1); Django: SECURE_PROXY_SSL_HEADER) so the app knows the original request was already HTTPS.

Cause 2: CDN "flexible" SSL with an HTTPS-only origin

Same family as cause 1, but caused by a CDN setting. "Flexible" mode means CDN to origin is HTTP. If the origin redirects HTTP to HTTPS, you loop. Switch to full/strict mode with a valid origin certificate.

Cause 3: www rule in two places, pointing opposite ways

The DNS host or CDN has a page rule sending www to the apex. The app config sends the apex to www. Each one undoes the other.

grep -rn "www" /etc/nginx/ | grep -i return
Enter fullscreen mode Exit fullscreen mode

Then check the CDN dashboard and the framework settings. Pick one canonical host and delete the opposite rule everywhere else.

Cause 4: trailing slash fights

The web server adds a trailing slash to directories. The app strips trailing slashes. /docs goes to /docs/, then back to /docs.

Common with static hosts plus an SPA router, or a CMS plus a custom nginx rule. Decide on one style and turn off the other layer's normalisation. Trailing slashes: the small URL detail that creates redirects covers how to choose.

Cause 5: cookie or auth based redirects

/dashboard redirects to /login when there's no session. /login redirects to /dashboard when it thinks there is one, maybe because a stale cookie is present but invalid. Users with that cookie loop forever, everyone else is fine.

These show up as "only some users" reports. Reproduce with the user's cookies:

curl -sIL --max-redirs 5 -H 'Cookie: session=abc123' https://example.com/dashboard
Enter fullscreen mode Exit fullscreen mode

Fix: the login page should validate the session, not just check that the cookie exists.

Cause 6: language or geo redirects

/ redirects to /en/ based on Accept-Language, and /en/ redirects to / because the default language shouldn't have a prefix. Or a geo rule bounces a visitor between country sites. Test with different headers:

for lang in en de fr; do
  echo "== $lang"
  curl -sIL --max-redirs 5 -H "Accept-Language: $lang" https://example.com/ | grep -i '^location'
done
Enter fullscreen mode Exit fullscreen mode

Find loops before users do

Loops often hide on URLs nobody visits from the homepage: old campaign links, legacy paths, uppercase variants. A crawl of internal links won't hit them. Feed in:

  • old sitemaps and analytics landing pages
  • URLs from email newsletters, which break in their own ways (see Why links in old email newsletters break)
  • uppercase, trailing-slash, http:// and non-www variants of top pages

A small generator for variants:

from urllib.parse import urlsplit, urlunsplit

def variants(url):
    s = urlsplit(url)
    hosts = {s.netloc, s.netloc.removeprefix("www."), "www." + s.netloc.removeprefix("www.")}
    paths = {s.path, s.path.rstrip("/") or "/", s.path if s.path.endswith("/") else s.path + "/"}
    for scheme in ("http", "https"):
        for h in hosts:
            for p in paths:
                yield urlunsplit((scheme, h, p, s.query, ""))
Enter fullscreen mode Exit fullscreen mode

Pipe those through a hop tracer and flag anything that repeats or exceeds 5 hops. A link checker like linkbruno.com will report redirect loops it meets during a crawl, but the variant list is what catches the ones no internal page links to.

My take

Every canonical rule (scheme, host, slash, language) needs exactly one owner. Write down which layer owns each one: CDN, load balancer, web server or app. Most loops I've traced came from a second layer "helpfully" adding the same rule after the first one already existed.

Over to you

Where do you put your HTTPS and www redirects: CDN, web server or app? And what's the strangest redirect loop you've had to untangle?

Top comments (0)