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
- Node 22 and Chrome 152, on a shell that is not sandboxed away from
127.0.0.1. - Two origins. The site answers
/p/<policy>with that policy,/p/defaultwith none. The receiver logs one line per request. Save asreferer-servers.mjs.
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'));
- Two driver scripts, saved two directories below
scripts/browser/session.mjs, which launches the installed Chrome throughpuppeteer-core.measure.mjswalks the policies,clicks.mjsfollows the two links.
// 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();
}
- The values in the query string are synthetic. Stop the servers afterwards:
netstat -ano | grep 9632, thenpowershell -Command "Stop-Process -Id <pid> -Force". - The browser figures are one capture on one machine on 2026-09-12. Another browser answers for itself.
Steps
- Step 1.
Start both origins.
node referer-servers.mjssite http://127.0.0.1:9632 receiver http://localhost:9633 - Step 2.
Load the page once per policy. Each load fetches two images from the receiver.
node measure.mjschrome : 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 - 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 declaredstrict-origin-when-cross-origin. That is the number to measure rather than carry over from another browser or an older note.no-referrer-when-downgradeandunsafe-urlboth handed over the full URL includingemail=dana.quill%40example.invalid, because this navigation was not a downgrade.#tab=2appears on no line at all: a fragment is stripped before the header is built. And everyel=no-referrerline is empty, which is the second image overriding the document policy with its ownreferrerpolicyattribute. - Step 4.
Now the navigation case. Both links point at the receiver, from a page whose policy is
unsafe-url, and one carriesrel="noreferrer".node clicks.mjsclicked: #plain -> http://localhost:9633/land?via=plain clicked: #norefer -> http://localhost:9633/land?via=rel-noreferrer - 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. - 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/echoreferer=(none) referer=http://127.0.0.1:9632/p/no-referrer?uid=u-8821The second value names a page that declares
no-referrer. No policy reached curl, because a policy is an instruction to a browser andRefereris 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
What to check next
- How to check referrer-policy header: reads the policy a site declares, without a browser.
- How to check for pii in urls: the identifiers that make a leaked
Referermatter, and the other places the same URL lands. - How to check which third parties receive data: the full list of origins a page hands a request to.
- How to check for tracking pixels: finds the one-pixel images that carry the header off-site.
- How to check if analytics ip is anonymized: what the receiving end keeps once the request arrives.
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.
Related on this site
intermediate12 minpublished updated Maks Verny