How to check redirect chain

Follow the hops and count them in one command: curl -sIL -o /dev/null -w '%{http_code} %{num_redirects} %{url_effective}\n' https://example.com/. The three values are the final status, the number of hops taken, and the URL the chain ended on. Expect 0 or 1 hops. Every hop past the first is a round trip a user pays for.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this after a domain move, a CMS migration or any change to rewrite rules, and on every release that touches routing. Each hop is a full request and response before the real page starts loading, with DNS, TCP and TLS on top when the hop changes host or scheme. A chain that grew from one hop to three adds two of those round trips. A chain that drops from HTTPS to HTTP sends the next request, cookies included, in clear text.

Prerequisites

const http = require('node:http');
http
  .createServer((req, res) => {
    if (req.url === '/a') res.writeHead(302, { location: '/b' });
    else if (req.url === '/b') res.writeHead(302, { location: '/a' });
    else res.writeHead(200, { 'content-type': 'text/plain' });
    res.end();
  })
  .listen(8081, '127.0.0.1', () => console.log('loop target on http://127.0.0.1:8081'));
const http = require('node:http');
// localhost is a second hostname for the same 127.0.0.1 socket.
const hops = {
  '/same': 'http://127.0.0.1:8941/end',
  '/port': 'http://127.0.0.1:8942/end',
  '/host': 'http://localhost:8942/end',
};
for (const port of [8941, 8942]) {
  http
    .createServer((req, res) => {
      if (hops[req.url]) {
        res.writeHead(302, { location: hops[req.url] });
        return res.end();
      }
      const auth = req.headers.authorization ? 'present' : 'absent';
      res.end(`port=${port} host=${req.headers.host} authorization=${auth}\n`);
    })
    .listen(port, '127.0.0.1', () => console.log(`listening on 127.0.0.1:${port}`));
}

Steps

  1. Step 1.

    Read the first hop on its own, before following anything.

    curl -sI https://httpbin.org/redirect/3 | grep -i -E '^HTTP/|^location'
    
    HTTP/2 302
    location: /relative-redirect/2

    Without -L curl stops here. This is the response your CDN and your monitoring see first.

  2. Step 2.

    Follow the chain and print every hop in order.

    curl -sIL https://httpbin.org/redirect/3 | grep -i -E '^HTTP/|^location'
    
    HTTP/2 302
    location: /relative-redirect/2
    HTTP/2 302
    location: /relative-redirect/1
    HTTP/2 302
    location: /get
    HTTP/2 200

    Three 302 responses, then the 200. Read the pairs top to bottom: each status belongs with the location under it.

  3. Step 3.

    Reduce the same chain to the three numbers a test can assert on.

    curl -sIL -o /dev/null -w 'code=%{http_code} hops=%{num_redirects} final=%{url_effective}\n' https://httpbin.org/redirect/3
    
    code=200 hops=3 final=https://httpbin.org/get

    %{http_code} is the last status, not the first. %{num_redirects} is the hop count, and it is the value to pin in a regression test.

  4. Step 4.

    Add %{num_connects} and watch for a scheme change hiding in the chain.

    curl -sL -o /dev/null -w 'code=%{http_code} hops=%{num_redirects} conns=%{num_connects} final=%{url_effective}\n' https://httpbin.org/absolute-redirect/2
    
    code=200 hops=2 conns=2 final=http://httpbin.org/get

    The request started on https and ended on http. Two connections were opened because the second scheme needs its own. A final URL that does not begin https is a defect whatever the status says.

  5. Step 5.

    Point curl at the loop target with a hop ceiling.

    curl -sL --max-redirs 10 -o /dev/null -w 'hops=%{num_redirects} final=%{url_effective}\n' http://127.0.0.1:8081/a; echo "exit=$?"
    
    hops=10 final=http://127.0.0.1:8081/a
    exit=47

    Exit 47 is "too many redirects". The final URL equals the starting URL, which is the signature of a loop rather than a long chain. Stop the Node process afterwards.

  6. Step 6.

    Print the hosts in a chain that leaves your domain.

    curl -sIL -w 'hops=%{num_redirects} conns=%{num_connects} final=%{url_effective}\n' 'https://httpbin.org/redirect-to?url=https://example.com/' | grep -i -E '^HTTP/|^location|^hops'
    
    HTTP/2 302
    location: https://example.com/
    HTTP/2 200
    hops=1 conns=2 final=https://example.com/

    conns=2: the 200 came over a new connection to example.com, not from the host under test.

  7. Step 7.

    Send credentials through each hop of the credential target with both flags.

    for f in -L --location-trusted; do echo "$f"; for p in same port host; do curl -s -u user:pass "$f" http://127.0.0.1:8941/$p; done; done
    
    -L
    port=8941 host=127.0.0.1:8941 authorization=present
    port=8942 host=127.0.0.1:8942 authorization=absent
    port=8942 host=localhost:8942 authorization=absent
    --location-trusted
    port=8941 host=127.0.0.1:8941 authorization=present
    port=8942 host=127.0.0.1:8942 authorization=present
    port=8942 host=localhost:8942 authorization=present

    With -L only the same-origin hop kept Authorization. curl withholds it from other origins, and a port change counts. --location-trusted sends it to every host.

  8. Step 8.

    Repeat with Node 22 fetch.

    node --input-type=module -e "for (const p of ['same', 'port', 'host']) { const r = await fetch('http://127.0.0.1:8941/' + p, { headers: { authorization: 'Bearer t0k3n' } }); process.stdout.write(p + ': ' + (await r.text())); }"
    
    same: port=8941 host=127.0.0.1:8941 authorization=present
    port: port=8942 host=127.0.0.1:8942 authorization=absent
    host: port=8942 host=localhost:8942 authorization=absent

    The same two hops lost it. Stop the Node process afterwards.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | hops=0 and code=200 | No redirect happened | Correct for a canonical URL. Wrong if the test expected a redirect. | | hops=1 and a final https:// URL | One clean hop | Nothing. This is the target shape for every entry point. | | hops=2 or more on one hostname | Rules are stacked, usually www plus trailing slash plus locale | Collapse them into one rule. Each hop is a full round trip. | | final=http://... | The chain downgraded the scheme | Fix before release. The next request travels in clear text. | | location names another host and conns rises by one | The chain left the domain you requested | Confirm the new host is expected. Authorization stops at this hop. | | authorization=absent after a port or host change | The client dropped credentials on a cross-origin hop | Call the final URL with credentials. --location-trusted sends them to every host in the chain. | | exit=47 with the final URL equal to the first | A redirect loop | Look for two rules that each rewrite the other's output. | | code=302 while the browser shows the page | curl stopped at the first hop | Add -L. Without it the status is the first hop, not the outcome. |

Common mistakes

Sign: A monitoring check asserts 200 and passes, while users land on a plain HTTP page.Cause: %{http_code} with -L reports the last status only. Step 4 shows a chain that ends 200 on http://httpbin.org/get after starting on https. Assert on %{url_effective} as well, or the scheme change never appears in a result.
Sign: curl reports one hop and the browser's Network tab shows three.Cause: The browser also follows redirects issued by HTML meta refresh and by JavaScript, which curl never sees. When the counts disagree, look at the page body before blaming the server configuration.
Sign: A chain works with curl and loops forever in a browser.Cause: The rule depends on a cookie or on a session that curl never sends. Add -b and -c so curl keeps the cookie jar across hops, then rerun. A login redirect that reads a cookie cannot be tested without one.
Sign: -I reports a redirect that a normal GET does not take.Cause: -I sends HEAD, and some servers route HEAD through different rules or reject it outright. Confirm anything surprising with -sL -o /dev/null, which sends GET and discards the body.

Thresholds

exit 47 after 10 hops

--max-redirs defaults to 50 and was set to 10 here. Curl stops and exits 47 at the ceiling. Set the ceiling to the number of hops you expect plus one, so a new hop fails the test instead of being followed silently.

Source: https://curl.se/docs/manpage.html#--max-redirs

What to check next

FAQ

How do I check a redirect with curl?

curl -sI URL shows the first hop and curl -sIL URL the whole chain. Add -w '%{num_redirects} %{url_effective}\n' for a value to assert on, not lines to read.

How do I check the number of redirect hops?

Print %{num_redirects} with -L, as in step 3. It counts hops followed, so a loop reports the --max-redirs ceiling, not infinity.

How do I check a redirect in Chrome?

Open DevTools, Network tab, tick Preserve log, then load the URL. Each hop appears as its own row with status 301 or 302. Click a row and read the Location value in the Headers panel.

How do I check a cross domain redirect?

Compare each location host with the one you requested, as in step 6; conns rises by one for a new host. After that hop the client stops sending Authorization, on a port change too, in curl and Node 22 fetch alike. Call the final URL with credentials instead.

Does curl follow redirects by default?

No. Without -L it prints the 3xx response and stops. That default is why a test can report a 301 as a failure when the chain behind it is healthy.

Is a 302 in a chain a problem?

Only if the destination is permanent. A 302 tells caches and search engines to keep asking the old URL, so a permanent move served as 302 keeps paying for the hop forever.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic9 minpublished updated Maks Verny