How to check which third parties receive data

List every request whose origin differs from the page, then read what each one carried: query string, POST body, Referer and Cookie. On the fixture below five requests went to one third party. Three carried nothing but the origin. Two carried an email address, a page path and a screen size.

Why check this

A list of third-party hosts is not a list of data recipients. A stylesheet, a logo and an analytics call all appear as requests to the same outside origin, and only one of them describes the person reading the page. A report that counts hosts says a font vendor and a data broker are the same finding, and the team stops reading it.

Run this before sign-off on any page that shows account data, and again after a tag manager change. The failure it catches is an address bar parameter, an email in this fixture, reaching an outside origin through the Referer header on requests nobody thinks of as tracking, including the stylesheet.

Prerequisites

// tp.mjs   the second origin. It logs what it received and serves three harmless files.
import { createServer } from 'node:http';
const files = {
  '/widget.css': ['text/css', 'body{font-family:system-ui}'],
  '/logo.png': ['image/png', ''],
  '/t.js': ['application/javascript', `
    fetch('http://127.0.0.1:9623/collect', {
      method: 'POST', headers: { 'content-type': 'text/plain' },
      body: JSON.stringify({ email: new URL(location.href).searchParams.get('email'),
                             path: location.pathname, screen: screen.width + 'x' + screen.height })
    });
    new Image().src = 'http://127.0.0.1:9623/px.gif?ev=view&uid=u-9f31';`],
};
createServer((req, res) => {
  const body = [];
  req.on('data', (c) => body.push(c));
  req.on('end', () => {
    console.log(JSON.stringify({
      method: req.method, url: req.url,
      referer: req.headers.referer ?? null,
      body: Buffer.concat(body).toString() || null,
    }));
    const path = req.url.split('?')[0];
    if (files[path]) {
      res.writeHead(200, { 'content-type': files[path][0], 'cache-control': 'no-store' });
      res.end(files[path][1]);
    } else {
      res.writeHead(204, { 'cache-control': 'no-store' });
      res.end();
    }
  });
}).listen(9623, '127.0.0.1', () => console.log('third party listening on 127.0.0.1:9623'));
// shop.mjs   the page under test. /leaky serves the same page with a wider referrer policy.
import { createServer } from 'node:http';
const T = 'http://127.0.0.1:9623';
const page = (leaky) => `<!doctype html><meta charset="utf-8"><title>Order 4417</title>
${leaky ? '<meta name="referrer" content="unsafe-url">' : ''}
<link rel="stylesheet" href="${T}/widget.css">
<h1>Order 4417</h1><img src="${T}/logo.png" alt="partner">
<script src="${T}/t.js"></script>`;
createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
  res.end(page(req.url.startsWith('/leaky')));
}).listen(9622, 'localhost', () => console.log('shop listening on localhost:9622'));
// carried.mjs   lists every cross-origin request and what it carried
import { open } from '../../scripts/browser/session.mjs';

const target = process.argv[2];
const s = await open();
try {
  const reqs = new Map();
  await s.cdp.send('Network.enable');
  s.cdp.on('Network.requestWillBeSent', (e) =>
    reqs.set(e.requestId, { url: e.request.url, type: e.type, method: e.request.method,
                            post: e.request.postData ?? null, headers: e.request.headers }));
  await s.goto(target);
  await new Promise((r) => setTimeout(r, 700));

  const pageOrigin = new URL(target).origin;
  console.log('page origin: ' + pageOrigin);
  console.log('');
  for (const r of reqs.values()) {
    const u = new URL(r.url);
    if (u.origin === pageOrigin) continue;
    const carried = [];
    const q = [...u.searchParams];
    if (q.length) carried.push('query   ' + q.map(([k, v]) => `${k}=${v}`).join('&'));
    if (r.post) carried.push('body    ' + r.post);
    const ref = r.headers.Referer ?? r.headers.referer;
    if (ref) carried.push('referer ' + ref);
    const ck = r.headers.Cookie ?? r.headers.cookie;
    if (ck) carried.push('cookie  ' + ck);
    console.log(`${r.method} ${u.origin}${u.pathname}  [${r.type}]`);
    console.log(carried.length ? carried.map((c) => '        ' + c).join('\n') : '        (nothing but the request itself)');
  }
} finally { await s.close(); }
// conscheck.mjs   what the browser reported about the same load
import { open } from '../../scripts/browser/session.mjs';
const s = await open();
try {
  await s.goto('http://localhost:9622/account/orders/4417?email=ada%40example.org');
  await new Promise((r) => setTimeout(r, 700));
  for (const m of s.console) console.log(m);
  for (const f of s.failed) console.log('failed: ' + f.url + '  ' + f.reason);
} finally { await s.close(); }

Steps

  1. Step 1.

    Load the page with an identifier in the address bar and list what each outside request carried.

    node carried.mjs "http://localhost:9622/account/orders/4417?email=ada%40example.org"
    
    page origin: http://localhost:9622
    
    GET http://127.0.0.1:9623/widget.css  [Stylesheet]
          referer http://localhost:9622/
    GET http://127.0.0.1:9623/logo.png  [Image]
          referer http://localhost:9622/
    GET http://127.0.0.1:9623/t.js  [Script]
          referer http://localhost:9622/
    POST http://127.0.0.1:9623/collect  [Fetch]
          body    {"email":"ada@example.org","path":"/account/orders/4417","screen":"800x600"}
          referer http://localhost:9622/
    GET http://127.0.0.1:9623/px.gif  [Image]
          query   ev=view&uid=u-9f31
          referer http://localhost:9622/

    Five requests, one outside origin, two findings. The stylesheet, the logo and the script carry only http://localhost:9622/, the origin of the page. The POST /collect carries the email address and the screen size, and GET /px.gif carries a user id. Those two are the data transfers. The other three are dependencies.

  2. Step 2.

    Confirm the finding on the receiving side, where nothing can be argued away.

    cat tp.log
    
    third party listening on 127.0.0.1:9623
    {"method":"GET","url":"/widget.css","referer":"http://localhost:9622/","body":null}
    {"method":"GET","url":"/t.js","referer":"http://localhost:9622/","body":null}
    {"method":"GET","url":"/logo.png","referer":"http://localhost:9622/","body":null}
    {"method":"POST","url":"/collect","referer":"http://localhost:9622/","body":"{\"email\":\"ada@example.org\",\"path\":\"/account/orders/4417\",\"screen\":\"800x600\"}"}
    {"method":"GET","url":"/px.gif?ev=view&uid=u-9f31","referer":"http://localhost:9622/","body":null}

    The server recorded the same five calls and the same body. The order of the three static files differs from the browser's list, and it differs again between runs, because they finish in whatever order the sockets complete.

  3. Step 3.

    Read what the browser reported about that same load.

    node conscheck.mjs
    
    error: Access to fetch at 'http://127.0.0.1:9623/collect' from origin 'http://localhost:9622' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
    error: Failed to load resource: net::ERR_FAILED
    pageerror: Uncaught (in promise) TypeError: Failed to fetch

    The POST /collect is a failed request. The console calls it blocked, the request list marks it net::ERR_FAILED, and the page threw. Step 2 shows the same request arriving at the third party with the email address in its body. CORS withheld the response from the page. It did not stop the data leaving.

  4. Step 4.

    Load the variant whose only difference is a referrer policy, and compare the same list.

    node carried.mjs "http://localhost:9622/leaky/account/orders/4417?email=ada%40example.org"
    
    page origin: http://localhost:9622
    
    GET http://127.0.0.1:9623/widget.css  [Stylesheet]
          referer http://localhost:9622/leaky/account/orders/4417?email=ada%40example.org
    GET http://127.0.0.1:9623/logo.png  [Image]
          referer http://localhost:9622/leaky/account/orders/4417?email=ada%40example.org
    GET http://127.0.0.1:9623/t.js  [Script]
          referer http://localhost:9622/leaky/account/orders/4417?email=ada%40example.org
    POST http://127.0.0.1:9623/collect  [Fetch]
          body    {"email":"ada@example.org","path":"/leaky/account/orders/4417","screen":"800x600"}
          referer http://localhost:9622/leaky/account/orders/4417?email=ada%40example.org
    GET http://127.0.0.1:9623/px.gif  [Image]
          query   ev=view&uid=u-9f31
          referer http://localhost:9622/leaky/account/orders/4417?email=ada%40example.org

    One <meta name="referrer" content="unsafe-url"> moved the stylesheet and the logo from the second column to the first. The email address now reaches the outside origin five times instead of once, and two of those requests are for files with no script in them.

  5. Step 5.

    Count the recipients rather than the requests, which is the number that belongs in a report.

    node carried.mjs "http://localhost:9622/account/orders/4417?email=ada%40example.org" | grep -c "^GET\|^POST"
    
    5

    Five cross-origin requests and one recipient origin. Report the origin, then the two requests that carried a value, and keep the other three out of the finding.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Only a referer line, showing an origin | A dependency, not a data transfer | Record the vendor, close the finding. | | A referer line showing a full path and query | Your URL is the payload | Set a Referrer-Policy and stop putting identifiers in URLs. | | A query or body line with a stable id | An identifier was handed over | Confirm it is covered by consent and by the contract with that vendor. | | A cookie line on a cross-origin request | The vendor recognises this browser from earlier visits | Check whether the cookie survives third-party restriction. | | A recipient that appears only after a click | A tag fired on interaction | Repeat the capture with the interaction included. |

Common mistakes

Sign: A vendor is cleared because its request has no query string and no body.Cause: The Referer header is a payload. In the second capture above a stylesheet request carried the whole page URL, email address included, to the same origin as the analytics call. Chrome sends the origin only by default, so this stays invisible until someone widens the policy, and then it applies to every request the page makes.
Sign: The report lists third-party hosts and the developers dispute all of it.Cause: A host count merges a font CDN with a data broker. Five requests here reached one origin and three of them described nobody. Report what was carried, per request, or the finding cannot be acted on.
Sign: A request shows as failed with a CORS error, so it is treated as one that did not happen.Cause: The console reported net::ERR_FAILED for POST /collect and the page threw TypeError: Failed to fetch. The third party's own log recorded the same request with the email address in the body. CORS decides what the page may read back, not what the browser may send. A red row in the Network panel is still a delivery.

What to check next

FAQ

How to check website trackers without an extension?

Drive the page over the DevTools Protocol, as in step 1, or read the Network panel row by row with the Headers panel open. An extension classifies by host list. This classifies by what each request carried, which is the question being asked.

How to see trackers on website pages behind a login?

Run the same capture on a signed-in session. Account pages are where identifiers appear in URLs, so they carry more in the Referer than a marketing page does, and the analytics call has more to describe.

How to check for trackers that only fire on a click?

A page-load capture misses them. Keep the driver running, perform the interaction, then print the list. Every request is timestamped by the protocol, so a request that appears only after the click is easy to attribute.

How to detect websites use trackers when the requests go to a first-party subdomain?

Compare hosts, not just registrable domains, and read the response. A subdomain that answers with a 204 and no body is an event endpoint whoever operates it. Where the data goes next is a server question, not a browser one.

How to identify websites that use trackers across a whole site?

Run the capture on one page per template, not on every URL. The tag set changes by template, and repeating a home page capture a thousand times produces the same five rows.

Verified

Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76

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.

intermediate15 minpublished updated Maks Verny