12 min read

Fixing Broken Redirect Chains

An audit that flags a "redirect chain" finding is telling you that a single requested URL takes three, four, or more hops before a client ever sees content. Every extra hop adds latency, burns crawl budget a crawler could have spent on pages that actually rank, and increases the odds that one link in the chain quietly breaks during an unrelated infrastructure change. This page is part of Writing Remediation Playbooks for Audit Failures and walks through the exact diagnostic-and-fix workflow: trace the chain with curl, confirm the real destination, generate a single-hop rewrite, and verify it before you consider the finding closed.

The fix is almost always the same shape regardless of which platform issued the original redirects — a CMS migration, a domain consolidation, an old CDN rule nobody deleted, or a URL structure change that layered a second redirect on top of a first one that was never removed. What changes is where the rewrite rule lives. The workflow below treats that as a variable and focuses on the part that is always true: find every hop, confirm the final one is a real 200, and collapse everything between the request and that destination into one 301.

Redirect Chain Collapse Top row shows the original broken chain: Requested URL to Old Path via 302, Old Path to Legacy Domain via 302, Legacy Domain to Final Page via 301, four boxes and three arrows. A downward arrow labeled "rewrite" separates the rows. Bottom row shows the fixed state: Requested URL directly to Final Page via a single 301, two boxes and one arrow. Before — 3 hops Requested URL /old-page 302 Old Path /blog/old-page 302 Legacy Domain old-brand.example 301 Final Page 200 OK rewrite After — 1 hop Requested URL /old-page 301 (single hop) Final Page 200 OK

Implementation

The script below does two things in one pass: it traces the full chain for a list of flagged URLs, and — for any URL where the hop count exceeds one — it emits a ready-to-review rewrite line mapping the original request straight to the final destination. It intentionally does not apply the rewrite itself; a redirect fix touches routing configuration, so the generated rules go to a review file first.

#!/usr/bin/env bash
# /opt/audit/redirects/trace_and_collapse.sh
# Trace flagged redirect chains and generate single-hop rewrite candidates.
set -euo pipefail

CURL_BIN="$(command -v curl)"
"$CURL_BIN" --version | head -n1 | grep -qE '7\.(6[8-9]|[7-9][0-9])|^curl 8' \
  || { echo "FAIL: curl 7.68+ required for --resolve stability" >&2; exit 1; }

INPUT_LIST="/opt/audit/redirects/input/flagged_urls.txt"   # one URL per line
OUT_DIR="/opt/audit/redirects/output"
RULES_FILE="$OUT_DIR/rewrite_candidates.tsv"
mkdir -p "$OUT_DIR"
: > "$RULES_FILE"

while IFS= read -r url; do
  [ -z "$url" ] && continue

  # -s silent, -I head-only... but -L follows redirects, so combine with a GET
  # trace: -sIL prints one full response header block per hop it follows.
  trace="$("$CURL_BIN" -sIL --max-redirs 15 --connect-timeout 5 "$url")"

  # Each hop's header block is separated by a blank line; count status lines.
  hop_count="$(printf '%s\n' "$trace" | grep -cE '^HTTP/[0-9.]+ [0-9]{3}')"

  # Final block is the last status line + last Location-less response.
  final_status="$(printf '%s\n' "$trace" | grep -E '^HTTP/[0-9.]+ [0-9]{3}' | tail -n1 | awk '{print $2}')"
  final_url="$("$CURL_BIN" -sIL --max-redirs 15 -o /dev/null -w '%{url_effective}' "$url")"

  echo "TRACE $url -> $hop_count hop(s), final=$final_status $final_url"

  # Only propose a rewrite when there is real chain depth AND the chain
  # actually lands on content, never on another redirect or a soft 404.
  if [ "$hop_count" -gt 2 ] && [ "$final_status" = "200" ]; then
    printf '%s\t%s\t%s\n' "$url" "$final_url" "$hop_count" >> "$RULES_FILE"
  fi
done < "$INPUT_LIST"

echo "Wrote $(wc -l < "$RULES_FILE") collapse candidate(s) -> $RULES_FILE"

Line-by-line, the parts that matter:

  • curl -sIL combines -s (suppress the progress meter), -I (fetch headers only — a HEAD request when the server honors it), and -L (follow every Location header). Run without -I, -L alone would fetch the full body of the final page, which you don't need to prove the chain shape.
  • --max-redirs 15 is a hard ceiling. Without it, a genuine loop makes curl follow redirects until it hits curl's own internal default limit, wasting time on a chain you already know is broken.
  • hop_count is derived by counting HTTP/ status lines in the raw trace, since one status line is emitted per hop curl follows, including the final one.
  • The second curl call with -w '%{url_effective}' is a separate, cheap request that asks curl directly for the URL it landed on, which is more reliable than parsing the last Location: header by hand.
  • The guard [ "$hop_count" -gt 2 ] intentionally treats a single legitimate redirect (old URL to new URL, one hop) as fine — it only flags chains of two or more redirects stacked on each other, which is what actually causes the audit finding.
  • Every candidate rule goes to a TSV file for manual review before anyone touches production routing. Nothing in this script writes to a live web server config.

The generated rewrite_candidates.tsv becomes the input to whatever your platform uses to define redirects — an nginx map block, a CDN edge rule, or a framework-level redirects table. For an nginx origin, converting a reviewed row is a one-line addition:

# /etc/nginx/conf.d/redirects.conf
# generated from rewrite_candidates.tsv row: /old-page  https://example.com/final-page  3
location = /old-page {
    return 301 https://example.com/final-page$is_args$args;
}

The $is_args$args pair is what forwards a query string unchanged; leaving it off is the single most common regression this fix introduces, covered below.

Verification

Re-run the trace against the fixed URL and assert the hop count dropped to exactly one, with the destination still returning 200.

set -euo pipefail

URL="https://example.com/old-page"

trace="$(curl -sIL --max-redirs 15 "$URL")"
hop_count="$(printf '%s\n' "$trace" | grep -cE '^HTTP/[0-9.]+ [0-9]{3}')"
final_status="$(printf '%s\n' "$trace" | grep -E '^HTTP/[0-9.]+ [0-9]{3}' | tail -n1 | awk '{print $2}')"

if [ "$hop_count" -eq 2 ] && [ "$final_status" = "200" ]; then
  echo "PASS: single redirect hop, final status $final_status"
else
  echo "FAIL: hop_count=$hop_count final_status=$final_status"
  exit 1
fi

Note that hop_count here reads as 2, not 1 — the redirect response itself is one status line and the final 200 is the second. A fixed URL always shows exactly two status lines: one 301 and one 200. Anything more means a hop is still hiding somewhere in the chain, usually a cache layer serving the old rule.

Failure Modes

A redirect loop instead of a chain

Some flagged URLs don't terminate at all — A redirects to B, and B redirects back to A, usually introduced when two separate teams each added a rule assuming the other side was the canonical one. curl -sIL with --max-redirs 15 will report curl's own redirect-limit error rather than a clean hop count.

# diagnostic: detect a loop by comparing effective URL to the original request
curl -sIL --max-redirs 20 -o /dev/null -w '%{http_code} %{url_effective}\n' "$URL" \
  || echo "LOOP SUSPECTED: exceeded max-redirs without reaching a terminal status"
# fix: remove one of the two conflicting rules, keeping only the rule that
# points toward the actual canonical page, then re-trace

Mixed HTTP and HTTPS hops

A chain that starts on http://, gets upgraded to https:// by one rule, then redirects to a different path by a second, separate rule counts as two hops even though only the path actually needed to change. Collapse the scheme upgrade and the path change into the same 301, issued at the edge or load balancer rather than the application layer, so the two concerns resolve in a single response.

# diagnostic: flag any trace where scheme changes mid-chain
curl -sIL "$URL" | grep -i '^location:' | grep -c '^location: http://' \
  && echo "one or more hops still forcing http:// mid-chain"

Query strings silently dropped after the fix ships

If the collapsed rewrite rule hardcodes the destination without forwarding $is_args$args (or your platform's equivalent), any request that arrived with tracking parameters or pagination state loses them after the fix. This often passes a basic audit re-check — the hop count is correct, the status is 200 — while breaking downstream attribution.

# diagnostic: confirm query strings survive the collapsed redirect
curl -sIL "https://example.com/old-page?utm_source=test" \
  | grep -i '^location:' | grep -q 'utm_source=test' \
  && echo "PASS: query string forwarded" \
  || echo "FAIL: query string dropped by rewrite rule"

FAQ

Is a 302 in the middle of a redirect chain always something to fix?

Not automatically, but treat it as suspicious. A 302 signals "temporary" to crawlers and search engines, so link equity and caching behavior differ from a 301. If the hop has been in place for more than a release cycle, it is de facto permanent and should be reissued as a 301 and then collapsed into the final single-hop rule along with everything else in the chain.

Why does curl -sIL sometimes report a different hop count than a browser?

Browsers apply HSTS upgrades and cached redirect entries before the request ever leaves the client, silently skipping hops that a fresh curl -sIL call still has to walk. Always trace with a clean cookie jar and pass --resolve if you need to bypass local DNS caching, and treat the curl trace as the audit source of truth rather than what you see in a browser's network panel.

How should I handle a chain that crosses from HTTP to HTTPS and then changes domains?

Collapse it in one rule at the edge, not in two separate hops. Configure the origin so the HTTP-to-HTTPS upgrade and the domain change happen in the same 301 response — most CDNs and load balancers support a single rewrite that changes both scheme and host, which keeps the chain at one hop even though two things changed at once.

What happens to query strings when I collapse a chain?

They are dropped by default unless your rewrite rule explicitly forwards them. Any collapsed rule generated from a trace must append the original query string to the destination URL, otherwise tracked campaign parameters and pagination state that worked across the old multi-hop chain silently disappear after the fix ships.