How to check referer header

Serve the same page under each Referrer-Policy value, load a cross-origin image from a receiver you control, and read what arrived. Chrome 152 sent the full query string under unsafe-url and under no-referrer-when-downgrade, the origin alone under no policy, and nothing at all under no-referrer.

Why check this

Run this when a page starts carrying an identifier in its URL, when a third-party tag is added, and after any change to the response headers. What a browser puts in Referer is decided by the policy in force at that moment, not by the sensitivity of the URL, so the answer moves under you without anyone editing the page.

The failure it prevents is an invitation or reset link ending up in someone else's request log. One image tag on the page is enough: the browser hands the whole URL to that origin, and the leak is invisible in the application, in the network panel summary, and in any review of the page's own code.

Prerequisites

import { createServer } from 'node:http';
import { appendFileSync } from 'node:fs';

const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');

// The site under test. /p/<policy> answers with that Referrer-Policy; /p/default sends none.
createServer((req, res) => {
  const policy = new URL(req.url, 'http://127.0.0.1:9632').pathname.slice(3);
  const headers = { 'content-type': 'text/html; charset=utf-8' };
  if (policy && policy !== 'default') headers['referrer-policy'] = policy;
  res.writeHead(200, headers);
  res.end(`<!doctype html><html><head><title>${policy}</title></head><body>
<img src="http://localhost:9633/px.gif?p=${policy}" alt="" width="1" height="1">
<img src="http://localhost:9633/px.gif?p=${policy}&el=no-referrer" referrerpolicy="no-referrer" alt="" width="1" height="1">
<a id="plain" href="http://localhost:9633/land?via=plain">plain link</a>
<a id="norefer" href="http://localhost:9633/land?via=rel-noreferrer" rel="noreferrer">opted out</a>
</body></html>`);
}).listen(9632, '127.0.0.1', () => console.log('site     http://127.0.0.1:9632'));

// The receiver. One log line per request: what was asked for, and what arrived in Referer.
createServer((req, res) => {
  const seen = req.headers.referer ?? '(none)';
  appendFileSync('policy.log', `${req.url.padEnd(46)} referer=${seen}\n`);
  if (req.url.startsWith('/echo')) {
    res.writeHead(200, { 'content-type': 'text/plain' }).end(`referer=${seen}\n`);
    return;
  }
  res.writeHead(200, { 'content-type': 'image/gif' }).end(GIF);
}).listen(9633, 'localhost', () => console.log('receiver http://localhost:9633'));
// measure.mjs
import { open } from '../../scripts/browser/session.mjs';

const POLICIES = ['default', 'no-referrer-when-downgrade', 'strict-origin-when-cross-origin',
  'unsafe-url', 'no-referrer', 'origin'];
const QS = '?uid=u-8821&email=dana.quill%40example.invalid#tab=2';

const s = await open();
try {
  console.log('chrome :', await s.browser.version());
  for (const p of POLICIES) {
    await s.goto(`http://127.0.0.1:9632/p/${p}${QS}`);
    console.log('visited:', p);
  }
} finally {
  await s.close();
}
// clicks.mjs
import { open } from '../../scripts/browser/session.mjs';

const PAGE = 'http://127.0.0.1:9632/p/unsafe-url?uid=u-8821&email=dana.quill%40example.invalid#tab=2';

const s = await open();
try {
  for (const id of ['#plain', '#norefer']) {
    await s.goto(PAGE);
    await Promise.all([s.page.waitForNavigation({ timeout: 15000 }), s.page.click(id)]);
    console.log('clicked:', id, '->', s.page.url());
  }
} finally {
  await s.close();
}

Steps

  1. Step 1.

    Start both origins.

    node referer-servers.mjs
    
    site     http://127.0.0.1:9632
    receiver http://localhost:9633
  2. Step 2.

    Load the page once per policy. Each load fetches two images from the receiver.

    node measure.mjs
    
    chrome : Chrome/152.0.7977.76
    visited: default
    visited: no-referrer-when-downgrade
    visited: strict-origin-when-cross-origin
    visited: unsafe-url
    visited: no-referrer
    visited: origin
  3. Step 3.

    Read what the receiver was sent. This is the measurement the rest of the page rests on.

    cat policy.log
    
    /px.gif?p=default                              referer=http://127.0.0.1:9632/
    /px.gif?p=default&el=no-referrer               referer=(none)
    /px.gif?p=no-referrer-when-downgrade&el=no-referrer referer=(none)
    /px.gif?p=no-referrer-when-downgrade           referer=http://127.0.0.1:9632/p/no-referrer-when-downgrade?uid=u-8821&email=dana.quill%40example.invalid
    /px.gif?p=strict-origin-when-cross-origin      referer=http://127.0.0.1:9632/
    /px.gif?p=strict-origin-when-cross-origin&el=no-referrer referer=(none)
    /px.gif?p=unsafe-url                           referer=http://127.0.0.1:9632/p/unsafe-url?uid=u-8821&email=dana.quill%40example.invalid
    /px.gif?p=unsafe-url&el=no-referrer            referer=(none)
    /px.gif?p=no-referrer                          referer=(none)
    /px.gif?p=no-referrer&el=no-referrer           referer=(none)
    /px.gif?p=origin                               referer=http://127.0.0.1:9632/
    /px.gif?p=origin&el=no-referrer                referer=(none)

    Six policies, three outcomes. With no policy header the receiver got http://127.0.0.1:9632/, the origin and nothing else, the same as the page that declared strict-origin-when-cross-origin. That is the number to measure rather than carry over from another browser or an older note. no-referrer-when-downgrade and unsafe-url both handed over the full URL including email=dana.quill%40example.invalid, because this navigation was not a downgrade. #tab=2 appears on no line at all: a fragment is stripped before the header is built. And every el=no-referrer line is empty, which is the second image overriding the document policy with its own referrerpolicy attribute.

  4. Step 4.

    Now the navigation case. Both links point at the receiver, from a page whose policy is unsafe-url, and one carries rel="noreferrer".

    node clicks.mjs
    
    clicked: #plain -> http://localhost:9633/land?via=plain
    clicked: #norefer -> http://localhost:9633/land?via=rel-noreferrer
  5. Step 5.

    Read the seven lines those two clicks added.

    tail -n 7 policy.log
    
    /px.gif?p=unsafe-url                           referer=http://127.0.0.1:9632/p/unsafe-url?uid=u-8821&email=dana.quill%40example.invalid
    /px.gif?p=unsafe-url&el=no-referrer            referer=(none)
    /land?via=plain                                referer=http://127.0.0.1:9632/p/unsafe-url?uid=u-8821&email=dana.quill%40example.invalid
    /favicon.ico                                   referer=http://localhost:9633/land?via=plain
    /px.gif?p=unsafe-url                           referer=http://127.0.0.1:9632/p/unsafe-url?uid=u-8821&email=dana.quill%40example.invalid
    /px.gif?p=unsafe-url&el=no-referrer            referer=(none)
    /land?via=rel-noreferrer                       referer=(none)

    The plain link handed the full URL to the destination. The link with rel="noreferrer" sent nothing, from the same document, under the same policy. One attribute on one element is the whole difference.

  6. Step 6.

    Ask the receiver what a client that is not a browser sends. The first request uses curl's default, the second sets the header by hand.

    curl -s http://localhost:9633/echo --next --referer "http://127.0.0.1:9632/p/no-referrer?uid=u-8821" http://localhost:9633/echo
    
    referer=(none)
    referer=http://127.0.0.1:9632/p/no-referrer?uid=u-8821

    The second value names a page that declares no-referrer. No policy reached curl, because a policy is an instruction to a browser and Referer is a string the client chooses.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The full path and query at the receiver | The policy in force allows the whole URL | Expect unsafe-url, or no-referrer-when-downgrade on a same-scheme request. Change both the policy and the URL. | | An origin with a trailing slash | An origin-only policy is in force | Confirm the origin itself is not the identifier, for example a customer subdomain. | | referer=(none) | no-referrer, or an element or link opting out | Check which. A document-wide policy and one attribute look identical here. | | No fragment on any line | Expected, fragments are stripped before the header is built | Do not treat the fragment as hidden. It stays in history and in location.hash. |

Common mistakes

Sign: A page sets Referrer-Policy: no-referrer-when-downgrade and the team reads that as a privacy setting.Cause: It only suppresses the header when the destination is less secure than the source. Step 3 measured it sending the whole query string, including the address, to a cross-origin receiver, while the page with no policy at all sent the origin alone.
Sign: The default is assumed rather than measured.Cause: With no Referrer-Policy header, Chrome 152 sent the origin and nothing more. A check written around a different default reports a leak that is not there, or misses one that is. Run step 3 against the browser your users have.
Sign: The audit reads the response header and stops.Cause: In step 3 one image overrode the document policy through its referrerpolicy attribute, and in step 5 a link with rel=noreferrer sent nothing while its neighbour sent the full URL. Element attributes win over the header, so the markup has to be read too.
Sign: Referer is used to decide something on the server, such as an anti-CSRF check or a gate on embedded content.Cause: Step 6 set the header to a page that declares no-referrer, from a client that never parsed a policy. Any value can be sent, and browsers legitimately send none, so both the presence and the content are attacker-controlled input.

What to check next

FAQ

How do I check the http referer header on a page I do not control?

Open DevTools, Network tab, pick a third-party request, and read Referer in the Request Headers panel. The receiver in step 1 is the same reading from the other side, with a log you can keep.

Is it spelled Referer or Referrer?

The request header is Referer, with one r, and that spelling is what req.headers.referer and the log lines above use. The response header is Referrer-Policy and the DOM property is document.referrer, both with two.

How do I test referrer leakage quickly?

Load the page with a receiver of your own as an image source, then read the log. Step 3 is that test, run once per policy value.

Does the fragment ever reach the receiver?

No. #tab=2 was on every URL in step 2 and on no line of the log. It stays in the browser, in history and in location.hash.

Why does step 5 show a favicon request with a referer from the receiver itself?

After the click, the landing page was on the receiver origin, so its favicon request was same-origin and carried that landing URL in full. Same-origin requests are the case a cross-origin policy does not cover.

Verified

Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76curl 8.21.0

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.

intermediate12 minpublished updated Maks Verny