Troubleshooting

ERR_TOO_MANY_REDIRECTS: find the two rules that loop and fix them

ERR_TOO_MANY_REDIRECTS means two redirect rules send the browser in circles. Behind Cloudflare, Flexible SSL is the usual cause: switch to Full (strict).

Published ~10 min read

Short answer: ERR_TOO_MANY_REDIRECTS means the browser followed redirect after redirect and never reached a page, so it gave up. Two rules disagree about the right URL and send the request back and forth. On a server behind Cloudflare the most common cause is the SSL/TLS mode Flexible combined with an origin that redirects HTTP to HTTPS: switch to Full (strict) with a certificate on the server. Everything else comes down to the redirect chain, which one curl command shows you.

Linux · macOS
curl -sSIL --max-redirs 10 https://example.com/ | grep -iE '^(HTTP|location)'
Windows PowerShell
curl.exe -sSIL --max-redirs 10 https://example.com/ | Select-String '^(HTTP|location)'

Replace https://example.com/ with the URL that fails. On Windows type curl.exe, not curl, which Windows PowerShell maps to Invoke-WebRequest. -s hides the progress bar and -S keeps curl’s error message, which -s alone would swallow. Each HTTP/ line is one response and each location: line is where it sends you next. A healthy chain ends in a 200 after at most one or two hops. A loop repeats the same addresses until curl stops.

What the error looks like in each browser

Every browser caps the number of redirects it follows for one page load. Chrome and Edge stop at 20, the limit the Fetch standard sets, and Firefox also defaults to 20. Hitting the cap gives these messages:

ClientWhat you seeRedirects followed
Chrome, EdgeThis page isn’t working · example.com redirected you too many times · ERR_TOO_MANY_REDIRECTS20
FirefoxThe page isn’t redirecting properly, with a hint about cookies20
SafariToo many redirects occurred trying to open …—
curl -Lcurl: (47) Maximum (10) redirects followed50 by default, or --max-redirs
GooglebotThe URL is reported as a redirect error in Search Console10

A loop is not slow, it is endless, so raising a limit never helps. The fix is always to find the two rules that disagree and make them agree.

How do I see the redirect loop?

Follow the chain with curl. A healthy chain, here a real one from Wikipedia, looks like this: HTTP goes to HTTPS, the bare domain goes to www, then a page.

Example output · curl -sSIL http://wikipedia.org/, filtered as above
HTTP/1.1 301 Moved Permanently
location: https://wikipedia.org/
HTTP/2 301
location: https://www.wikipedia.org/
HTTP/2 200

A loop looks like one of these two patterns:

Representative loop · the same URL again and again
curl: (47) Maximum (10) redirects followed
HTTP/2 301
location: https://example.com/
HTTP/2 301
location: https://example.com/
HTTP/2 301
location: https://example.com/
...

The error line usually comes first: curl writes it straight to the terminal, while the headers reach the screen through grep.

  • The same URL every time: the server keeps redirecting to the address you already requested. It cannot see that the request already arrived there. That is almost always HTTPS being lost on the way, at Cloudflare or at a proxy in front of the app: see cause 1 and cause 4.
  • Two URLs taking turns (www then bare domain, with and without a trailing slash): two rules fight over the canonical address, see cause 2 and cause 3.

-I sends a HEAD request. A few applications answer HEAD differently from GET, so if the chain looks clean but the browser still loops, repeat it as a real GET:

Linux · macOS · GET instead of HEAD
curl -sSL --max-redirs 10 -o /dev/null -D - https://example.com/ | grep -iE '^(HTTP|location)'

Without a shell at hand, paste the URL into our HTTP header and redirect checker. It requests the page from our server in Amsterdam without cookies, follows up to 10 redirects and lists every hop with its status code and Location, and says so when it stopped at the limit. In the browser, DevTools shows the same: open the Network tab, tick Preserve log, reload, and read the status and Location of each 301 or 302 row.

Cause 1: Cloudflare Flexible plus an HTTPS redirect on the server

In Flexible mode Cloudflare talks HTTPS to the visitor but plain HTTP to your server on port 80, for every request. Your server sees HTTP, applies its own rule “HTTP goes to HTTPS” and answers with a 301 to https://example.com/. The browser asks Cloudflare for that HTTPS URL, Cloudflare again fetches it from your server over HTTP, and the server answers with the same 301. That is the same-URL loop above, and every hop carries server: cloudflare.

Encryption modeCloudflare reaches your server overResult
FlexibleHTTP, port 80, alwaysRedirect loop
FullThe visitor's scheme: HTTPS on 443 for HTTPS visitors; certificate not checkedWorks, but anyone in the path could present a fake certificate
Full (strict)Same as Full, and the certificate must be valid for the hostnameWorks; the setting to use

Prove it before you change anything. Ask your server directly, bypassing Cloudflare, exactly the way Flexible does. YOUR_SERVER_IP is the origin address from your hosting panel, not a Cloudflare address:

Linux · macOS · test the origin directly
# What Cloudflare gets in Flexible mode: plain HTTP to port 80
curl -sSI --resolve example.com:80:YOUR_SERVER_IP http://example.com/

# What Cloudflare gets in Full (strict): HTTPS to port 443
curl -sSI --resolve example.com:443:YOUR_SERVER_IP https://example.com/

If the first command answers 301 with location: https://example.com/, Flexible is your loop. If the second fails with a certificate error, fix the certificate first, or Full (strict) will turn the loop into an error 526. (Add -k for the moment if you use a Cloudflare Origin CA certificate: curl does not trust that CA, Cloudflare does.)

  1. 1Put a certificate on the server

    Either a Let’s Encrypt certificate from certbot, or a free Cloudflare Origin CA certificate (SSL/TLS → Origin Server, Create Certificate), which only Cloudflare trusts and therefore only works on proxied (orange-cloud) records. If certbot’s HTTP validation fails because Let’s Encrypt runs into the same loop, take the Origin CA certificate: Cloudflare issues it without fetching anything from your server.

  2. 2Serve HTTPS on port 443

    An nginx server block with listen 443 ssl;, ssl_certificate and ssl_certificate_key, as in the blocks below. Allow 443 in the firewall. The second curl above must now return your page.

  3. 3Switch the mode to Full (strict)

    In the Cloudflare dashboard under SSL/TLS → Overview. If the zone is on Automatic SSL/TLS and is still on Flexible because the server had no HTTPS when it was last scanned, do not wait for the next scan, which runs about once a month: choose the custom option and set Full (strict) yourself.

  4. 4Keep exactly one HTTP to HTTPS rule

    In Full (strict) your server’s own redirect is safe: an HTTP visitor reaches it over HTTP and is sent to HTTPS once, and HTTPS requests arrive as HTTPS. You can instead let Cloudflare make that hop with Always Use HTTPS (SSL/TLS → Edge Certificates); in Full (strict) the two do not conflict.

The mirror image exists too: a server rule that sends HTTPS back to HTTP loops as soon as something else forces HTTPS again, such as Always Use HTTPS, HSTS or a second rule elsewhere. Old configs that “force HTTP” for a sub-path are the usual source. Cloudflare Redirect Rules or Page Rules that contradict your server’s rules are the other Cloudflare-side source; the --resolve test shows which side answers.

Cause 2: www and bare-domain rules that fight

Pick one canonical address, say https://example.com, and make every rule point at exactly that. The loop appears when two layers disagree: Cloudflare sends example.com to www while nginx sends www back, or the app’s configured URL says www while the web server strips it. A single nginx block can loop on its own when it redirects to a name it also serves:

nginx · loops: www is redirected to itself
server {
    listen 443 ssl;
    server_name example.com www.example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    return 301 https://www.example.com$request_uri;
}

A request for www.example.com matches this block and is redirected to www.example.com, forever. The clean layout uses one block per job: redirect blocks that never serve content, and a content block that never redirects to its own name. This version sends everything to the bare domain in one hop:

nginx · /etc/nginx/sites-available/example.com
# 1. Plain HTTP, both names: one hop straight to the canonical HTTPS URL
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

# 2. HTTPS on www: one hop to the bare domain
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name www.example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    return 301 https://example.com$request_uri;
}

# 3. The canonical site itself: no redirect to its own name in here
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    root /var/www/example.com;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Replace example.com, the certificate paths and the root with your own; the certificate must cover both names. Then test and reload:

Debian · Ubuntu
sudo nginx -t && sudo systemctl reload nginx

Want www as the canonical name instead? Swap the names in blocks 2 and 3 and in both return lines. Then align every other layer with it: Cloudflare Redirect Rules, the app’s base URL, and in WordPress the two URL settings below. sudo nginx -T | grep -nE 'server_name|return 30|rewrite' lists every redirect nginx has loaded, including ones in files you forgot.

Cause 3: WordPress URL settings and HTTPS detection

WordPress redirects a front-end request to the host in its home setting when the two differ only by www, and when siteurl starts with https:// it forces HTTPS for the login page and the admin area. If home says www.example.com while nginx or Cloudflare strips the www, or the other way round, you get the two-URL loop. Check both settings with WP-CLI, run as the user that owns the site files:

Debian · Ubuntu · WP-CLI
sudo -u www-data wp option get home --path=/var/www/example.com
sudo -u www-data wp option get siteurl --path=/var/www/example.com

# set both to the canonical URL your server redirects to
sudo -u www-data wp option update home 'https://example.com' --path=/var/www/example.com
sudo -u www-data wp option update siteurl 'https://example.com' --path=/var/www/example.com

Without WP-CLI, put define( 'WP_HOME', 'https://example.com' ); and define( 'WP_SITEURL', 'https://example.com' ); into wp-config.php; they override the database values.

The second WordPress loop hits only when TLS ends before PHP: Cloudflare Flexible, a load balancer, or nginx terminating HTTPS and proxying to Apache on port 8080. WordPress decides whether a request is HTTPS from $_SERVER['HTTPS'], sees plain HTTP, and sends the login and admin pages to HTTPS again; a “force SSL” plugin does the same for every page. When nginx itself terminates TLS and talks FastCGI to PHP-FPM, this does not happen: Debian’s and Ubuntu’s fastcgi_params pass HTTPS on already. For the proxy case, add this to wp-config.php above the line /* That's all, stop editing! Happy publishing. */:

PHP · wp-config.php
// TLS ends at a proxy in front of this server: trust its X-Forwarded-Proto
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] )
	&& strpos( $_SERVER['HTTP_X_FORWARDED_PROTO'], 'https' ) !== false ) {
	$_SERVER['HTTPS'] = 'on';
}

Use it only when every request reaches PHP through that proxy, because a client that talks to the server directly can send the header too. If neither fixes it, a plugin is redirecting (SEO, redirection, cache or “force SSL” plugins). Turn them all off from the shell, retest, and turn them back on one by one:

Debian · Ubuntu · WP-CLI
sudo -u www-data wp plugin list --status=active --path=/var/www/example.com
sudo -u www-data wp plugin deactivate --all --path=/var/www/example.com

On Apache, check .htaccess as well. The usual HTTPS rule tests %{HTTPS}, which is off for every request when TLS ends at Cloudflare Flexible or at a proxy, so it redirects forever. Let it also accept the scheme the proxy reports, and put it above the # BEGIN WordPress block, which WordPress rewrites on its own:

Apache · .htaccess
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

Both conditions must hold, so a request that reached the proxy over HTTPS is left alone. The same caveat as for the PHP snippet applies: trust the header only when all traffic comes through the proxy.

Cause 4: an app redirect behind nginx

The same mechanism hits any framework behind a reverse proxy. Django with SECURE_SSL_REDIRECT, Express or Laravel with an HTTPS middleware, or a Node app with its own “force HTTPS” check all look at the connection they received, which is plain HTTP from nginx on 127.0.0.1. They redirect to HTTPS, nginx passes the answer on, and the browser comes back over HTTPS into the same situation. nginx has to tell the app the original scheme:

nginx · inside the HTTPS server block
location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
}

Then the app must trust that header from nginx: in Express app.set('trust proxy', 'loopback'), in Django SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"), in Laravel the trusted-proxies setting. Django’s own documentation names exactly this loop as the symptom of a missing setting. $scheme is the scheme of the connection nginx received, so behind Cloudflare Flexible it is still http: fix cause 1 first.

The complete proxy block with certificates, WebSockets and real client IPs is in our nginx reverse proxy guide.

Cause 5: a loop that depends on cookies

If the loop happens in your browser but curl and the header checker, which send no cookies, see a clean chain, a cookie decides the redirect. Typical patterns:

  • Login loops: the login page redirects to the dashboard because a session cookie exists, and the dashboard redirects back because the session behind it is invalid or expired. Both checks must use the same validation, and an invalid cookie must be deleted rather than just ignored.
  • Cookie set on the wrong host: you log in on www.example.com, the cookie is scoped to www, a rule sends you to example.com where the cookie is missing, and the app sends you back to log in. Fixing cause 2 fixes this.
  • Secure session cookies behind a proxy: an app that believes the request came over plain HTTP (cause 1 or 4) may not issue its Secure session cookie at all; express-session, for one, needs trust proxy for that. The login seems to work, no session arrives, and the next page sends you back to log in.
  • The opposite case: a site that sets a language, consent or “cookie test” cookie and redirects until it comes back loops for any client that refuses cookies. That is why Firefox mentions cookies in its error. Repeat the curl command with -b /dev/null added, which switches on curl’s cookie engine: if the loop disappears, the site needs a cookie to stop redirecting.

As a visitor, delete the cookies of that one site. In Chrome: Settings → Privacy and security → Third-party cookies → See all site data and permissions, search the site, Delete. In Firefox: click the icon left of the address and choose Clear cookies and site data. Test in a private window first; if the site loads there, your cookies or cache were the problem.

Fixed on the server, still looping in your browser?

Browsers cache permanent redirects. A 301 or 308 may be reused without asking the server again, so after you remove a bad rule your own browser can keep replaying it. Test in a private window, or clear the cache for the site. HSTS can do something similar: once a site sent Strict-Transport-Security, the browser rewrites HTTP to HTTPS itself (Chrome shows it as a 307 Internal Redirect in DevTools), so a server rule that sends HTTPS back to HTTP loops for that browser until the rule is gone. Because of the cache, use 302 while you experiment with redirect rules and switch to 301 once the chain is right.

A cache in front of the application can replay the old redirect as well. Empty the page cache of a WordPress cache plugin, and if a Cloudflare Cache Rule caches your HTML, purge it under Caching → Configuration → Purge Everything: a cached 301 without its own Cache-Control header stays at Cloudflare for two hours by default.

On your own server you can read every layer’s rule, from Cloudflare through nginx to PHP and the app, instead of guessing. VMHeaven’s VPS in Amsterdam and VPS in Frankfurt come with full root and a browser VNC console in the panel for when a config change locks you out.

Frequently asked

Does clearing cookies fix ERR_TOO_MANY_REDIRECTS?

Only when a cookie causes the loop, for example a stale login session. Delete that one site's cookies or open it in a private window. If it loops there too, or in curl, the server's redirect rules are at fault and only fixing them helps.

Why does Cloudflare cause too many redirects?

In the SSL/TLS mode Flexible, Cloudflare fetches every page from your server over plain HTTP. If the server redirects HTTP to HTTPS, it answers every request with that same redirect. Install a certificate on the server and set the mode to Full (strict).

How many redirects does a browser follow before it gives up?

Chrome, Edge and Firefox stop after 20, the curl command-line tool after 50 unless --max-redirs sets another limit, and Googlebot after 10. A redirect loop never ends, so the limit only decides when the error appears.

Related articles

All troubleshooting