Cookies on Tamperan

We use cookies and similar technologies for the things below. You can accept all, reject everything except what's essential, or pick what you're OK with.

Preferences
Remembers things like your last workspace and how you had a list sorted. Improves the experience but the site works without.
Improvement
Anonymous usage measurement so we can fix bugs and prioritise work.
Marketing
Lets us measure whether ads we run send people who actually use the site. We don't share personal data with advertisers.

Read our cookies policy and the privacy policy.

Loading…

Writing

The third consent state nobody builds

Consent banners give you two states and the law has three. Here's a cookieless measurement mode for visitors who haven't decided yet, why Article 5(3) isn't engaged by it, and how to prove your own tracking works when you cannot see it from your own network.

Shane Wright privacy analytics gdpr go

Does this survive a DPO?

It depends entirely on which kind of DPO you've, and that's worth knowing before you build it.

An in-house DPO who understands the product will engage with the actual argument. They will ask what's stored and what's transmitted before consent - the evidence, not the principle - and if you can show no access to information stored on the device, the Article 5(3) reasoning holds up, because it's the correct reading and they can see that it is.

An outsourced or fractional DPO applies a template, and the template has two states in it. You won't win that conversation with a better argument, because the argument isn't what's being evaluated. Expect a no, and expect it quickly.

That's not a reason to build the two-state version. It's a reason to find out which conversation you're going to have before you spend a fortnight on the third state - and to have the evidence ready in the form of a network trace rather than a design document.

Most consent implementations have two states: consented, or not. So analytics either runs or it doesn't, and "hasn't decided yet" gets treated as "no".

That's safe and it's expensive. On a site with any real traffic, the visitors who leave before touching the banner are a large share of everyone, and you know nothing about them - not that they arrived, not what they read, not that they bounced.

There's a third state, and the law is clearer about it than the tooling is. Undecided isn't the same as refused, and you can measure the first group without touching their device at all - which is what the consent rule for storage is actually about.

The rule that actually applies

For storage, the operative text isn't GDPR. It's ePrivacy Article 5(3), which is triggered by storing information on, or accessing information already stored on, a user's device.

Cookies, localStorage, sessionStorage, IndexedDB. That's the trigger. Not "processing personal data" - touching the device.

Which means measurement that writes nothing to the device doesn't engage Article 5(3) at all. The storage question - the one the banner exists for - can be answered by not storing anything.

Whether the processing then needs a GDPR lawful basis is a separate question with a separate answer, and it's not one your analytics library gets to make. It belongs to whoever holds data protection in your organisation.

If you're a small SaaS, that should still be a named person even though Article 37 probably doesn't compel you to designate one. Somebody has to own it, and "the company" isn't a person who can be asked a question. In practice it's often the founder.

If that's you, then the useful thing I can tell you is: get your own legal advice on this one. Not because the reasoning below is weak - I think it holds

  • but because you're the controller, the decision is yours, and a decision of that kind should not rest on somebody else's blog post. Mine included.

Three states, not two

So the gate has three answers, not two:

state what runs
consent granted full analytics
no decision yet cookieless measurement, no device storage
consent refused nothing at all

The third row isn't optional. A visitor who clicked Reject has actively refused, and giving them a degraded version of the thing they refused is worse than giving them nothing - it's the behaviour that gets a company written about.

In our Go library that's two predicates rather than one:

ConsentGate: func(rc web.RequestContextInterface) bool {
    return consent.Has(rc.Context(), consent.CategoryAnalytics)
},
DegradedGate: func(rc web.RequestContextInterface) bool {
    return !consent.Get(rc.Context()).Decided
},

DegradedGate is consulted only when ConsentGate returns false, and it answers the narrower question: not "did they consent" but "have they decided". Undecided gets degraded measurement. Refused gets nothing.

What "cookieless" has to mean, exactly

Loosely-specified is useless here, because the claim is legal rather than aesthetic. The degraded configuration is:

posthog.init(key, {
  api_host: '/.ph',
  persistence: 'memory',
  autocapture: false,
  disable_session_recording: true,
  person_profiles: 'never',
})

Two implementation details that matter more than they look.

The options go into init, never applied afterwards. Initialising normally and then switching to memory persistence writes a cookie in between. Device storage without consent, for the few milliseconds before you undo it, is still device storage without consent.

Verify the claim rather than asserting it. We checked, on load and after navigation and interaction, that this configuration writes nothing to cookies, localStorage, sessionStorage or IndexedDB. No identifier survives a page load - each pageview gets a fresh distinct_id and session_id - and no person profile is created.

What you get from it: pageviews, referrers, paths, and no way to connect two of them. Which is exactly the trade. You learn that a page was read and nothing about who read it.

Serve it first-party

If your snippet points at a vendor's domain, a meaningful share of your visitors never load it, and you'll never know which ones, so your numbers are wrong in a direction you cannot measure.

Every app proxies analytics through its own origin - /.ph on the same host - so the request is first-party. That's not a way around consent; the gate above still decides whether anything is sent at all. It's that a first-party request is the only kind you can reason about.

Server-side capture is a different problem

Client-side gating is the visible half. Server-side capture is a different problem and most implementations quietly get it wrong.

Our server-side events - sign-ins, background jobs, LLM call accounting - fire from request handlers and are not consent-gated. That's a deliberate decision, and it's only defensible because of what those events contain: ids, roles, status, model and token counts. No names, no raw event properties, no PII. The rule written into the library is that adding PII to a server-side event is a compliance change, not a feature.

If you need per-user server-side gating, it has to happen at the call site. A client-side consent gate doesn't reach the server, and assuming otherwise is how you end up with an unlawful data set that looks fine in the browser devtools.

Now try to verify any of this from your own network

You can't, and three separate things you did on purpose are stopping you.

The consent gate. Nothing emits until consent is granted, so a check has to go through the banner properly, every time.

Your own IP is excluded. Ours is, on every app, so our traffic never pollutes the numbers. Behind Cloudflare the client IP comes from CF-Connecting-IP, which Cloudflare sets and you cannot forge from outside. So from the office, the snippet is suppressed and there's nothing to look at.

Bot user agents are dropped client-side. posthog-js drops declared bots, and HeadlessChrome is on that list. Drive it with Playwright and you get zero events however far you drive it. This one costs people an afternoon reliably.

Each is correct. Together they mean "is analytics working?" cannot be answered by looking.

What to do instead

Read what was served. curl the page and check whether the snippet is there and which api_host it names:

curl -s https://example.com/ | grep -o "posthog.init([^)]*)"

Check the client state, not the network. In a browser, evaluate posthog.config and inspect storage. Watching for a network call that's never going to happen tells you nothing.

Test as a visitor who isn't you. The trick that actually works: run curl from a container on the same network as the app, talking to it directly, with the header your CDN would set:

docker run --rm --network <app-net> alpine/curl -s \
  -H "Host: example.com" \
  -H "CF-Connecting-IP: 203.0.113.9" \
  http://app:8080/ | grep -c posthog.init

Then repeat with your own IP in that header and confirm you get nothing. That second half is the important one - it proves the exclusion is excluding you and not everybody.

Read the container environment for the server side. That's the ground truth for whether keys are set at all, and it needs no browser.

What I'd do differently

Write the verification procedure at the same time as the gate. Fail-closed analytics is correct and it's unusually hard to observe, and the two facts are related - every mechanism that makes it safe also makes it invisible. I spent a day convinced something was broken when the actual situation was three layers of deliberate design all working perfectly.

Lawful basis is a separate question

Degraded measurement answers the storage question. It doesn't answer whether you have a lawful basis for the processing, which is a separate analysis belonging to whoever owns data protection where you work - and if that's you, to your lawyer.

It's also a judgement call that a regulator hasn't blessed. The reasoning - Article 5(3) is about device storage, so writing nothing doesn't engage it - looks sound to me and I'd defend it for our own sites.

That's not the same as it being right for yours. I am an engineer, not your lawyer, and this is precisely the sort of decision to take advice on rather than inherit from someone who doesn't know your processing, your jurisdiction mix or your risk appetite. If yours is lower than mine, the two-state model is always available and always safe.

If you've run this argument past a DPO and it didn't survive, I'd like to know where it fell down.