This article was written with AI assistance and reviewed before publishing. The demo site used in the examples is made up for this article.
On August 24, 2022, California's Attorney General announced a $1.2 million settlement with Sephora. The allegations were simple: Sephora sold customers' personal information without telling them, and it failed to process opt-out requests sent through user-enabled global privacy controls such as the Global Privacy Control (GPC) [1]. On the same day, the office said it had sent notices to a number of other businesses over the same failure to process GPC opt-outs [1].
Nobody at Sephora had to click a button for that to happen. A shopper's browser sent a signal, the site did not act on it, and advertising tags kept loading. Almost three years later, in July 2025, the same office announced a $1.55 million settlement with Healthline.com, describing it at the time as the largest CCPA settlement to date. One of the allegations was that Healthline kept sharing data with some advertising third parties even for consumers who had opted out, and the announcement spelled out that businesses must honour opt-out requests, including those sent through GPC [2].
If you ship a site with a Meta Pixel, a TikTok Pixel or a LinkedIn Insight Tag on it, this is a frontend problem as much as a legal one. This post walks through what the signal actually is, what "honouring" it means in code, and how to catch the gaps in CI with an open-source CLI called gpc-warden.
What GPC actually sends
Global Privacy Control is a W3C specification. When a user turns it on, the browser does two things [3]:
- It adds a
Sec-GPC: 1request header to the requests it makes. - It exposes
navigator.globalPrivacyControl, a read-only boolean that tells your scripts whether that header was sent for the current top-level page.
The spec also defines an optional support resource at /.well-known/gpc.json. Its gpc member must be true (the server intends to abide by GPC requests, at least as far as it is legally obliged to) or false, and an optional lastUpdate member holds a YYYY-MM-DD date [3]. A valid file looks like this:
{ "gpc": true, "lastUpdate": "2026-10-20" }
California's own CCPA guidance lists the right to opt out of the sale or sharing of personal information "including via the GPC", and tells consumers they can submit an opt-out request through a user-enabled global privacy control [4]. So the header is not a hint. Under that guidance it is a request your site is expected to act on.
Why sites get this wrong
Reading a boolean is easy. The hard part is that most ad tags are pasted straight into a layout as a bare <script async src="...">. A tag loaded like that runs on every page view, and there is no point at which your code gets to ask "did this visitor opt out?" before the pixel fires.
Honouring GPC in a static or mostly static site comes down to two things:
- Some code has to read the signal: an inline script, a loader in your own JS bundle, an edge or serverless function that looks at
Sec-GPC, or a consent management platform (CMP) configured to respect it. - Every sale/share tag has to load in a way that code can stop: through that loader, or tagged so your CMP controls it (for example
type="text/plain"plus a category attribute).
Miss either one and the signal is effectively ignored, even if your privacy policy says otherwise.
Checking a built site with gpc-warden
gpc-warden is a zero-dependency Node.js CLI, library and GitHub Action that inspects the folder your build writes (Astro, Next.js output: 'export', Eleventy, Hugo, Jekyll, Docusaurus, VitePress or plain HTML). It runs offline and checks:
- whether any inline script, local JS file or edge/serverless function in the build reads
navigator.globalPrivacyControlorSec-GPC; - whether advertising vendors that sell or share data (Meta, Google Ads, TikTok, LinkedIn, X, Pinterest, Snap, Microsoft, Criteo, Taboola and others) load unconditionally;
- whether
/.well-known/gpc.jsonexists, is valid, and does not sayfalsewhile sale/share tags are present; - whether the privacy policy mentions GPC or opt-out preference signals.
It needs Node.js 18 or newer:
npx gpc-warden dist
npx gpc-warden dist --fail-on medium
npx gpc-warden out --format sarif > gpc.sarif
Here is a run against a small demo shop (made up for this article). The home page pastes in the Meta and TikTok pixels directly, there is no gpc.json, and the privacy page only talks about a "Do Not Sell" link:
<!-- demo/before/index.html (made-up demo) -->
<script async src="https://connect.facebook.net/en_US/fbevents.js"></script>
<script async src="https://analytics.tiktok.com/i18n/pixel/events.js"></script>
Three high findings fail the run with exit code 1: no code reads the signal, and each pixel loads unconditionally. The two low findings point at the missing gpc.json and the policy wording. Every finding comes with a concrete fix.
Fixing the demo
The fix is a small loader that only injects the sale/share tags when the browser has not sent a GPC opt-out:
// demo/after/js/ads.js (made-up demo)
// Load sale/share tags only when the browser has not sent a GPC opt-out.
if (navigator.globalPrivacyControl !== true) {
for (const src of [
'https://connect.facebook.net/en_US/fbevents.js',
'https://analytics.tiktok.com/i18n/pixel/events.js',
]) {
const s = document.createElement('script');
s.async = true;
s.src = src;
document.head.appendChild(s);
}
}
The page now includes <script src="/js/ads.js"></script> instead of the two pixels, the build publishes /.well-known/gpc.json, and the privacy policy says a GPC signal is treated as a request to opt out of sale and sharing. In a real site you would also combine this with your consent logic and read Sec-GPC server-side where you set cookies or forward data. Re-running the check:
gpc-warden — https://shop.example
2 page(s); 0 sale/share vendor(s); GPC handling found in js/ads.js
no findings
✔ Passing (fail-on: high).
If you use a CMP such as OneTrust, Osano, Usercentrics, Didomi, Termly, Ketch, TrustArc or Transcend, gpc-warden spots it, but it cannot see your dashboard settings. Once GPC support is switched on in the CMP, pass --cmp-honours-gpc (or set "cmpHonoursGpc": true in config) to confirm it.
Running it in CI
The repository ships a GitHub Action that runs the bundled CLI with no install step. Build the site first, then point the action at the output folder:
permissions:
contents: read
security-events: write # only needed for the SARIF upload
jobs:
gpc-warden:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { persist-credentials: false }
- run: npm ci && npm run build
- uses: MrLouisWright/gpc-warden@v1
with:
path: dist
fail-on: high
sarif: gpc-warden.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: gpc-warden.sarif }
By default it also adds a Markdown report to the job summary. Exit codes are 0 for a pass, 1 for findings at or above --fail-on, and 2 for usage or config errors, so it works the same way in any other CI system with a plain npx gpc-warden dist step.
For pages that genuinely do not need the check, the config file supports a time-boxed allowlist. Each entry needs a reason, and once its expires date passes the finding comes back along with an allowlist-expired finding:
{
"failOn": "medium",
"allowlist": [
{
"rule": "gpc-tracker-not-suppressed",
"subject": "/landing/*",
"reason": "Landing pages served only outside the US",
"expires": "2027-03-31"
}
]
}
There is also a small library API if you want to wire the checks into your own tooling:
import { check, validateGpcJson } from "gpc-warden";
const report = check("dist");
validateGpcJson(JSON.stringify({ gpc: true, lastUpdate: "2026-10-01" })); // null
What it does not do
gpc-warden is a static check of your build output. It cannot prove that a tag is actually suppressed at runtime, it does not audit consent banners (a sibling tool, consent-warden, covers those) or build a full tracker inventory (tracker-warden does that), and it is not legal advice. What it does is catch the most common, most mechanical mistake: an ad tag sitting in the HTML where no GPC check can ever reach it.
The takeaway
Regulators in California have already treated an ignored GPC signal as an ignored opt-out, first with Sephora [1] and again with Healthline [2]. The spec gives you a clear header, a clear JavaScript property and a clear declaration file [3]. Gate your sale/share tags behind the signal, publish gpc.json, say so in your policy, and let CI fail the build when a pixel slips back in.
- Project site: https://mrlouiswright.github.io/gpc-warden/
- npm: https://www.npmjs.com/package/gpc-warden
- GitHub: https://github.com/MrLouisWright/gpc-warden
Louis Wright
References
- State of California Department of Justice, Office of the Attorney General. "Attorney General Bonta Announces Settlement with Sephora as Part of Ongoing Enforcement of California Consumer Privacy Act". 2022. https://oag.ca.gov/news/press-releases/attorney-general-bonta-announces-settlement-sephora-part-ongoing-enforcement
- State of California Department of Justice, Office of the Attorney General. "Attorney General Bonta Announces Largest CCPA Settlement to Date, Secures $1.55 Million from Healthline.com". 2025. https://oag.ca.gov/news/press-releases/attorney-general-bonta-announces-largest-ccpa-settlement-date-secures-155
- W3C (Zimmeck, S., Snyder, P., Brookman, J., Zucker-Scharff, A., eds.). "Global Privacy Control (GPC)". W3C Working Draft, 2026. https://www.w3.org/TR/gpc/
- State of California Department of Justice, Office of the Attorney General. "California Consumer Privacy Act (CCPA)". Accessed Oct 2026. https://oag.ca.gov/privacy/ccpa


Top comments (0)