┌── POST 09.08 · Cookie Audits & Scanners · 8 min read

Which Script Set That Cookie? Attributing First-Party Cookies to Third-Party Code

A cookie's domain says where it is sent, not who wrote it. On shopify.com, 15 of 25 cookies sat on .shopify.com and only 5 came from Shopify's own server; the rest were written by Google and Meta code and a same-domain tagging server. A document.cookie hook with stack traces, plus one DevTools Protocol event, attributes every write.

TL;DR

A cookie scanner tells you a cookie’s domain. It cannot tell you who wrote it, and on a modern site the two have come apart: _ga and _fbp sit on your domain and were written by Google’s and Meta’s code. We loaded the shopify.com homepage in a clean browser: 15 of its 25 cookies were on .shopify.com, and only 5 of those 15 were written by Shopify’s own server. The other 10 came from Google code served off a first-party subdomain, from Meta’s pixel script, and from a same-domain tagging server. Here is how to attribute every cookie to the code that set it: a document.cookie hook with a stack trace for JavaScript writes, and one Chrome DevTools Protocol event for HTTP writes.

Every scan report has a domain column, and readers treat it as an owner column. It is not one. A cookie’s domain records where the browser will send it, which is a fact about the cookie jar, not about the code that wrote it. Consent attaches to purpose and to who receives the data, and neither of those is in the jar.

Why the domain column stopped meaning anything

Three mechanisms put third-party code’s cookies on your domain, and all three are standard.

JavaScript writes. A script runs in your page’s origin whatever server it came from. When gtag.js writes _ga, the cookie is yours. Google’s own documentation says its tags set cookies “on the highest level of domain possible”, so blog.example.com gets a cookie on example.com, and its cookie reference lists _ga and _gcl_au as “Set from partner domain”. Meta’s documentation says the same of _fbp: when the pixel “uses first-party cookies, the Pixel automatically saves a unique identifier to an _fbp cookie”. Both are first-party by domain and third-party by authorship.

First-party serving. Google tag gateway for advertisers “lets you load Google scripts, such as gtm.js, directly from your first-party infrastructure instead of from Google’s servers”. Once that is on, even the script URL looks like yours. On shopify.com, gtag.js is served from gtm.shopify.com. The heuristic “the writer’s host is foreign” fails there too.

Server-set cookies from a same-domain tagging server. Google’s custom-domain guidance is explicit that a tagging server on the default domain “can only set Javascript cookies”, while one on your origin or a subdomain gets server-set, HttpOnly cookies. Those never pass through document.cookie at all, so no page-level hook sees them. CNAME cloaking, where a subdomain of yours resolves to a tracker’s host, is the older version of the same move; Dimova and colleagues documented its rise in PoPETs 2021.

So a scanner that reads the jar, or even document.cookie, reports a domain that is correct and useless. Attribution needs two different instruments, one per write path.

Path one: JavaScript writes, with a stack trace

document.cookie is an accessor on Document.prototype. Replace the setter, and every write passes through your function with a call stack attached. The stack’s frames name the script that made the write, because Error().stack lists the file URL of every frame. There is a second write API, cookieStore.set(), asynchronous, secure contexts only, and listed by MDN as Baseline across browsers since June 2025, so wrap that too, or a tracker that uses it walks straight past you.

(() => {
  window.__cookieWrites = [];
  const frames = s => [...s.matchAll(/(https?:\/\/[^\s)]+?):\d+:\d+/g)].map(m => m[1]);
  const record = (api, v) => {
    const f = frames(new Error().stack);
    window.__cookieWrites.push({
      api, name: String(v).split('=')[0].split(';')[0].trim(),
      writer: f[0] || 'inline',           // innermost frame: the code that wrote it
      entry:  f[f.length - 1] || 'inline' // outermost frame: what loaded that code
    });
  };
  const d = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
  Object.defineProperty(Document.prototype, 'cookie', {
    configurable: true,
    get() { return d.get.call(this); },
    set(v) { record('document.cookie', v); return d.set.call(this, v); }
  });
  if (window.CookieStore) {
    const s = CookieStore.prototype.set;
    CookieStore.prototype.set = function (...a) {
      record('cookieStore.set', typeof a[0] === 'string' ? a[0] : a[0].name);
      return s.apply(this, a);
    };
  }
})();

Two frames are worth keeping. The innermost is the writer, the file whose code executed the assignment. The outermost is the entry point, which for a tag fired by a tag manager is the container script. When the writer is gtag.js and the entry is gtm.js, you have the cookie, the vendor and the trigger in one record.

The hook has to be in place before the page’s first script runs, which is what Playwright’s add_init_script is for: it runs “after the document was created but before any of its scripts were run”. Pasting it into the console after load misses everything that already happened.

Path two: HTTP writes, from the network stack

Set-Cookie headers never touch the page. Playwright’s response.headers_array() does return them, with “headers with multiple entries, such as Set-Cookie” appearing “multiple times”, and for many audits that is enough. The Chrome DevTools Protocol goes one step further. Network.responseReceivedExtraInfo is “fired when additional information about a responseReceived event is available from the network stack”, and its payload includes the raw headers and a blockedCookies list with a reason for every Set-Cookie the browser refused. A header the browser rejected does not appear in the jar, and a jar-only scan cannot tell you it was ever attempted.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    ctx = p.chromium.launch().new_context()
    page = ctx.new_page()
    page.add_init_script(HOOK)                      # the JavaScript above

    cdp = ctx.new_cdp_session(page)
    cdp.send("Network.enable")
    urls, http_sets = {}, []
    cdp.on("Network.requestWillBeSent",
           lambda e: urls.__setitem__(e["requestId"], e["request"]["url"]))

    def on_extra(e):
        blocked = {b["cookieLine"].split("=")[0].strip(): b["blockedReasons"]
                   for b in e.get("blockedCookies", [])}
        for k, v in e.get("headers", {}).items():
            if k.lower() == "set-cookie":
                for line in v.split("\n"):
                    name = line.split("=")[0].strip()
                    http_sets.append({"name": name, "url": urls.get(e["requestId"]),
                                      "httpOnly": "httponly" in line.lower(),
                                      "blocked": blocked.get(name)})
    cdp.on("Network.responseReceivedExtraInfo", on_extra)

    page.goto("https://example.com/", wait_until="networkidle")
    js_sets = page.evaluate("() => window.__cookieWrites")
    jar = ctx.cookies()

Do not click the banner. The point is to see what writes before anyone has agreed to anything, the same discipline as the pre-consent Playwright script, with the writer’s identity added to each finding.

What it finds on a controlled page

We built a page on 127.0.0.1 that loads a script from localhost, a different host, and made that script write one cookie through document.cookie, one through cookieStore.set(), and fetch a response carrying its own Set-Cookie. The page’s server set an HttpOnly session cookie. All six writes were attributed to the right origin:

site_pref   document.cookie  <- http://127.0.0.1:8801/               own script
_tp_id      document.cookie  <- http://localhost:8802/tracker.js     third-party script
_tp_cs      cookieStore.set  <- http://localhost:8802/tracker.js     third-party script
session     Set-Cookie       <- http://127.0.0.1:8801/               own server, HttpOnly
_tp_srv     Set-Cookie       <- http://localhost:8802/tracker.js     blocked: SameSiteUnspecifiedTreatedAsLax
_tp_px      Set-Cookie       <- http://localhost:8802/pixel.gif      blocked: SameSiteUnspecifiedTreatedAsLax

The jar held four cookies. The last two lines were attempted and refused, and only the network event says so. A scanner reporting “four cookies” would be right about the jar and wrong about what the page tried to do.

What it finds on a real one

We ran the same script against the shopify.com homepage on 8 September 2026 from Mexico City, so outside the EU, in a fresh context with no banner interaction. This is a measurement of authorship, not of compliance; it says nothing about what an EU visitor is shown. The jar had 25 cookies, 15 of them on .shopify.com. Attribution split those 15 four ways:

Written by Path Cookies
Shopify’s own server Set-Cookie 5: _shopify_essential_, _shopify_y, _shopify_s, _merchant_essential, _merchant_analytics
gtm.js and gtag.js, served from gtm.shopify.com document.cookie 5: _ga, _ga_W6NECZNE63, _gcl_au, mto_pvs, li_fat_id_s
connect.facebook.net/en_US/fbevents.js document.cookie 1: _fbp
Tagging server at gtm.shopify.com/g/collect Set-Cookie 4: FPID (HttpOnly), FPLC, FPAU, FPGSID

Every row is “first-party” by domain. Only the first row is first-party by authorship. The _ga write shows the value of keeping both stack frames: the writer is gtag.js, and the URL it was served from is a Shopify subdomain. A scanner classifying by script host would file Google Analytics under Shopify. The fourth row is invisible to any page-level instrument, including the hook above: FPID is HttpOnly and arrived on a response to the analytics collection endpoint, so it exists only in the network event and in the jar.

Where this stops

  • Scripts can detect the hook. The property descriptor on Document.prototype is inspectable, and a script that cares can read it. None of the tags above do, but do not treat the hook as tamper-proof.
  • Minified stacks name files, not tags. A write from gtm.js tells you which container fired, not which of its forty tags. Pair the timestamp with the container’s preview mode, or with the outbound request that followed, to get the tag.
  • Cross-origin iframes need their own hook. Playwright’s init script runs in child frames too, but the writes land in that frame’s window, not the top page’s array. Collect per frame.
  • Cookies are not the whole picture. The same identifier can live in storage that is not a cookie, and a write you attribute correctly is still one your refusal path may not be able to remove.

Method

Measured 8 September 2026 with Playwright 1.62.0 driving Chromium 151.0.7922.34, fresh context per run, no banner interaction, from Mexico City. Protocol semantics from the Chrome DevTools Protocol reference; Playwright behaviour from its Python API reference; Google cookie behaviour from the tag platform documentation, the ads cookie reference and the server-side tagging custom-domain and dependency-serving pages; _fbp from Meta’s Conversions API parameters page; cookieStore availability from MDN. Repeated runs of gtag write _ga_* several times per load; the counts above are distinct cookie names. Tags change without notice; re-run before relying on any row.

The domain column is the start of an audit, not the end of one. Audit your site and see which code writes each cookie, before and after consent, with CookieInspector.

C
About the author
Consent Mode HQ
Editorial team at Consent Mode HQ
Read more by author ↗