Diagnostic Cloudflare
ERR_TOO_MANY_REDIRECTS is almost always Flexible SSL
The site was fine yesterday. Now every request ends in ERR_TOO_MANY_REDIRECTS and the origin logs show the same request arriving over and over. Nothing changed on the server. What changed is that the origin started forcing HTTPS — a plugin, a new vhost, a security header — while Cloudflare is still set to Flexible, which means it talks to the origin over plain HTTP. The origin redirects to HTTPS, Cloudflare answers that redirect, and the two of them loop until the browser gives up.
Flexible encrypts the visitor-to-Cloudflare hop and uses plain HTTP from Cloudflare to your origin. If the origin redirects HTTP to HTTPS, that redirect comes back through Cloudflare, which requests over HTTP again. That is the loop.
Set the zone's SSL mode to Full (strict) if the origin has a valid certificate, or Full if it has a self-signed one. It is one API call, and it fixes the majority of these.
The problem in plain words
The browser reports a redirect loop and nothing else. Server logs show repeated requests for the same path with a 301 or 302 response, which reads as the origin misbehaving. It is not; it is doing exactly what it was configured to do, to a request that arrives over HTTP because Cloudflare sent it that way.
What makes it confusing is that it appears without a deploy. Enabling a "force HTTPS" option in a CMS, installing a security plugin, or a hosting provider turning on HTTPS redirection by default will all trigger it against a Cloudflare zone that has been on Flexible for years.
Why it happens
Flexible exists for origins that cannot do TLS at all. It was a reasonable option when certificates were expensive and awkward. With free certificates everywhere it is now mostly a trap, and it is still selectable.
The padlock lies about the second hop. Visitors see HTTPS and assume the connection is encrypted end to end. Between Cloudflare and the origin it is plaintext, which is a security problem independent of the redirect loop.
Both ends are behaving correctly. Cloudflare is honouring your setting; the origin is honouring its configuration. Nothing is broken in isolation, which is why the cause is hard to see from either side alone.
How to fix it
Read the zone's SSL mode
One call answers the question, and it is worth checking before touching the origin at all.
curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/ssl" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['result']['value'])"
# "flexible" is the answer you are looking for
Pick the right mode rather than the permissive one
Full (strict) validates the origin certificate and is what you want with any real certificate, including a free Cloudflare Origin CA one. Full encrypts but does not validate, which is right only for a self-signed certificate you have not replaced yet. Never go back to Flexible to make an error go away.
Check for redirect rules stacking on top
An "Always Use HTTPS" setting or a redirect rule in Cloudflare, combined with an origin that also redirects, can produce a loop even after the SSL mode is right. The script reports both so you see the whole picture rather than fixing one and rediscovering the other.
Purge the cache afterwards
A cached 301 outlives the fix. Browsers also cache permanent redirects aggressively, so test in a private window or with curl rather than trusting a reload.
How to check it worked
Follow the redirects yourself and count them:
curl -sIL https://example.com | grep -E '^(HTTP|location)'
# one 200, or at most a single 301 to the canonical host
Then confirm the second hop is actually encrypted: with Full (strict), a deliberately broken origin certificate should produce a 526 rather than a silent fallback.
The full code
The script reads the SSL mode, the Always Use HTTPS setting and any redirect rules, and reports the combinations that produce a loop. Changing the mode requires --apply because it affects every request to the zone immediately.
"""Detect the Cloudflare settings combination that causes a redirect loop.
Flexible SSL plus an origin that forces HTTPS is the classic cause: Cloudflare
requests over HTTP, the origin redirects to HTTPS, Cloudflare follows it back. Both
ends are behaving correctly, which is why it is hard to see from either one.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cloudflare_ssl_mode_check")
API = "https://api.cloudflare.com/client/v4"
def diagnose(ssl_mode, always_https, origin_forces_https):
"""Pure decision function over three settings.
The loop needs a plaintext hop AND something redirecting it back. Either alone
is fine, which is why this checks the combination rather than the SSL mode on
its own.
"""
problems = []
if ssl_mode == "off":
problems.append("SSL is off entirely; visitors are unencrypted")
if ssl_mode == "flexible":
if origin_forces_https:
problems.append("Flexible SSL with an origin that forces HTTPS -- this is "
"the redirect loop. Set Full (strict).")
else:
problems.append("Flexible SSL: the Cloudflare-to-origin hop is plaintext "
"even though visitors see a padlock")
if ssl_mode == "full":
problems.append("Full (not strict) does not validate the origin certificate; "
"use Full (strict) unless the origin is self-signed")
if always_https and origin_forces_https and ssl_mode in ("flexible", "off"):
problems.append("Always Use HTTPS and an origin redirect are stacked on a "
"plaintext origin hop")
return problems
def get(session, url):
r = session.get(url, timeout=30)
r.raise_for_status()
return r.json().get("result", {})
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--zone-id", required=True)
ap.add_argument("--origin-forces-https", action="store_true",
help="set if the origin redirects http to https")
ap.add_argument("--set-mode", choices=["full", "strict"],
help="'strict' maps to Cloudflare's full(strict)")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
token = os.environ.get("CF_API_TOKEN")
if not token:
log.error("set CF_API_TOKEN")
return 2
s = requests.Session()
s.headers.update({"Authorization": f"Bearer {token}"})
ssl_mode = get(s, f"{API}/zones/{args.zone_id}/settings/ssl").get("value")
always = get(s, f"{API}/zones/{args.zone_id}/settings/always_use_https").get("value") == "on"
log.info("ssl mode=%s always_use_https=%s origin_forces_https=%s",
ssl_mode, always, args.origin_forces_https)
problems = diagnose(ssl_mode, always, args.origin_forces_https)
for p in problems:
log.error(p)
if args.set_mode:
value = "strict" if args.set_mode == "strict" else "full"
if args.apply:
s.patch(f"{API}/zones/{args.zone_id}/settings/ssl",
json={"value": value}, timeout=30).raise_for_status()
log.info("ssl mode set to %s -- purge the cache, a 301 outlives the fix", value)
else:
log.info("WOULD set ssl mode to %s -- pass --apply", value)
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Detect the Cloudflare settings combination that causes a redirect loop.
*
* Flexible SSL plus an origin that forces HTTPS is the classic cause. Both ends are
* behaving correctly, which is why it is hard to see from either one.
*/
const API = 'https://api.cloudflare.com/client/v4';
/**
* Pure decision function over three settings.
*
* The loop needs a plaintext hop AND something redirecting it back. Either alone is
* fine, which is why this checks the combination.
*/
export function diagnose(sslMode, alwaysHttps, originForcesHttps) {
const problems = [];
if (sslMode === 'off') problems.push('SSL is off entirely; visitors are unencrypted');
if (sslMode === 'flexible') {
problems.push(originForcesHttps
? 'Flexible SSL with an origin that forces HTTPS -- this is the redirect loop. Set Full (strict).'
: 'Flexible SSL: the Cloudflare-to-origin hop is plaintext even though visitors see a padlock');
}
if (sslMode === 'full') {
problems.push('Full (not strict) does not validate the origin certificate; '
+ 'use Full (strict) unless the origin is self-signed');
}
if (alwaysHttps && originForcesHttps && ['flexible', 'off'].includes(sslMode)) {
problems.push('Always Use HTTPS and an origin redirect are stacked on a plaintext origin hop');
}
return problems;
}
async function main() {
const zone = process.argv[process.argv.indexOf('--zone-id') + 1];
const originForces = process.argv.includes('--origin-forces-https');
const apply = process.argv.includes('--apply');
const setMode = process.argv[process.argv.indexOf('--set-mode') + 1];
const token = process.env.CF_API_TOKEN;
if (!token) { console.error('set CF_API_TOKEN'); process.exit(2); }
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const get = async (path) => (await (await fetch(`${API}${path}`, { headers })).json()).result ?? {};
const sslMode = (await get(`/zones/${zone}/settings/ssl`)).value;
const always = (await get(`/zones/${zone}/settings/always_use_https`)).value === 'on';
console.log(`ssl mode=${sslMode} always_use_https=${always} origin_forces_https=${originForces}`);
const problems = diagnose(sslMode, always, originForces);
problems.forEach((p) => console.error(p));
if (process.argv.includes('--set-mode')) {
const value = setMode === 'strict' ? 'strict' : 'full';
if (apply) {
await fetch(`${API}/zones/${zone}/settings/ssl`,
{ method: 'PATCH', headers, body: JSON.stringify({ value }) });
console.log(`ssl mode set to ${value} -- purge the cache, a 301 outlives the fix`);
} else {
console.log(`WOULD set ssl mode to ${value} -- pass --apply`);
}
}
process.exit(problems.length ? 1 : 0);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
The point of the rule is that neither setting is wrong on its own. Flexible without an origin redirect is insecure but works; an origin redirect without Flexible is correct. Only the pair loops, and the tests say so.
from cloudflare_ssl_mode_check import diagnose
def test_strict_with_a_redirecting_origin_is_fine():
assert diagnose("strict", True, True) == []
def test_flexible_plus_origin_redirect_is_the_loop():
problems = diagnose("flexible", False, True)
assert any("redirect loop" in p for p in problems)
def test_flexible_alone_is_still_flagged_as_insecure():
"""It works, but the second hop is plaintext behind a padlock."""
problems = diagnose("flexible", False, False)
assert problems and not any("redirect loop" in p for p in problems)
def test_full_without_strict_is_flagged():
assert any("does not validate" in p for p in diagnose("full", False, False))
def test_ssl_off_is_reported():
assert any("SSL is off" in p for p in diagnose("off", False, False))
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { diagnose } from './cloudflare-ssl-mode-check.mjs';
test('strict with a redirecting origin is fine', () => {
assert.deepEqual(diagnose('strict', true, true), []);
});
test('flexible plus an origin redirect is the loop', () => {
assert.ok(diagnose('flexible', false, true).some((p) => p.includes('redirect loop')));
});
test('flexible alone is still flagged as insecure', () => {
const p = diagnose('flexible', false, false);
assert.ok(p.length && !p.some((x) => x.includes('redirect loop')));
});
test('full without strict is flagged', () => {
assert.ok(diagnose('full', false, false).some((p) => p.includes('does not validate')));
});
FAQ
Why does Flexible SSL cause a redirect loop?
Flexible means Cloudflare talks to your origin over plain HTTP. If the origin redirects HTTP to HTTPS, that redirect travels back through Cloudflare, which makes the same HTTP request again. Both ends are behaving correctly; the pairing is what loops.
Which mode should I use?
Full (strict) if the origin has a valid certificate, including a free Cloudflare Origin CA one. Full only if it is self-signed and you have not replaced it yet. Flexible is a trap now that certificates are free.
Is Flexible insecure even when it works?
Yes. Visitors see a padlock, but the hop between Cloudflare and your origin is plaintext. Anyone able to observe that path sees the traffic in the clear, which is a problem independent of the redirect loop.
It appeared without a deploy. How?
Something on the origin started forcing HTTPS — a CMS setting, a security plugin, or a host enabling redirection by default. The Cloudflare side had been on Flexible for years and only became a problem when the origin changed.
I fixed the mode and it still loops. Why?
Check for an Always Use HTTPS setting or a redirect rule in Cloudflare stacking on top of the origin's own redirect, and purge the cache — browsers cache a permanent redirect aggressively, so test with curl or a private window.
Related field notes
- A rule that never applies because the record is grey-clouded
- www and apex configured inconsistently
- A cache purge that reports success and clears nothing
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Encryption modes — Cloudflare docs
- Cloudflare API documentation
- Troubleshooting redirect loops — Cloudflare docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.