How to check if analytics ip is anonymized
Point the tag at a collector you control, send hits over IPv4 and IPv6, and read the line the collector wrote. In the run below the IPv4 address was stored as 127.0.0.0 and the IPv6 address was stored whole, from the same code, on hits that both carried aip=1.
Why check this
Run this when a collector, a proxy or a server-side tagging endpoint is introduced or changed, and at sign-off on any release that touches the tag. The claim under test is not "the vendor anonymizes", it is "this deployment stores less than it received". Only the receiving end can answer that, so the check has to read what was written, not what was asked for.
The failure it prevents is a truncation that runs on some visitors and not others. Nothing errors, no dashboard changes, and the retained rows quietly hold full addresses for the part of the audience that arrived over a protocol the code did not consider.
Prerequisites
- Node 22, Chrome 152 and curl, on a shell that is not sandboxed away from
127.0.0.1. - A collector listening on both address families, and a page that fires one hit into it. Save as
collector.mjs. No hit leaves the machine.
import { createServer } from 'node:http';
import { appendFileSync } from 'node:fs';
const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
// The truncation most analytics back ends ship: zero the last octet of an IPv4 address.
const truncate = (ip) => ip.replace(/\.\d+$/, '.0');
// One line per hit. This file is what the collector kept.
const collect = (req, res) => {
const q = new URL(req.url, 'http://localhost:9634').searchParams;
const peer = req.socket.remoteAddress;
appendFileSync('collector.log', JSON.stringify({
cid: q.get('cid'), aip: q.get('aip'),
peer, family: req.socket.remoteFamily, stored: truncate(peer),
xff: req.headers['x-forwarded-for'] ?? null, dl: q.get('dl'),
}) + '\n');
res.writeHead(200, { 'content-type': 'image/gif' }).end(GIF);
};
createServer(collect).listen(9634, '127.0.0.1', () => console.log('collector http://127.0.0.1:9634'));
createServer(collect).listen(9634, '::1', () => console.log('collector http://[::1]:9634'));
// A page that fires one hit, the way a tag does.
createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(`<!doctype html><html><head><title>Pricing</title></head><body><h1>Pricing</h1>
<script>new Image().src = 'http://localhost:9634/collect?tid=T-TEST&cid=c-91f2&aip=1&dl='
+ encodeURIComponent(location.href);</script></body></html>`);
}).listen(9635, '127.0.0.1', () => console.log('site http://127.0.0.1:9635'));
- A one-page visitor, saved two directories below
scripts/browser/session.mjs, which drives the installed Chrome throughpuppeteer-core.
// hit.mjs
import { open } from '../../scripts/browser/session.mjs';
const s = await open();
try {
console.log('chrome :', await s.browser.version());
await s.goto(process.argv[2]);
for (const r of s.requests) console.log('request:', r.status, r.url);
} finally {
await s.close();
}
- Loopback addresses stand in for client addresses here. What is under test is the code path that shortens them, not the values, and a deployment behind a proxy sees the proxy instead. Stop the servers afterwards:
netstat -ano | grep 9635, thenpowershell -Command "Stop-Process -Id <pid> -Force".
Steps
- Step 1.
Start the collector and the page. Both listeners share one handler, so IPv4 and IPv6 hits run the same code.
node collector.mjscollector http://127.0.0.1:9634 collector http://[::1]:9634 site http://127.0.0.1:9635 - Step 2.
Load the page in Chrome and let the tag fire on its own.
node hit.mjs "http://127.0.0.1:9635/pricing?plan=team&email=dana.quill%40example.invalid"chrome : Chrome/152.0.7977.76 request: 200 http://127.0.0.1:9635/pricing?plan=team&email=dana.quill%40example.invalid request: 200 http://localhost:9634/collect?tid=T-TEST&cid=c-91f2&aip=1&dl=http%3A%2F%2F127.0.0.1%3A9635%2Fpricing%3Fplan%3Dteam%26email%3Ddana.quill%2540example.invalid request: 200 http://127.0.0.1:9635/favicon.ico - Step 3.
Read the line the collector stored for that hit.
cat collector.log{"cid":"c-91f2","aip":"1","peer":"::1","family":"IPv6","stored":"::1","xff":null,"dl":"http://127.0.0.1:9635/pricing?plan=team&email=dana.quill%40example.invalid"}The hit asked for anonymization with
aip=1and the address was stored whole. Chrome resolvedlocalhostto::1, the request arrived over IPv6, and a truncation written for dotted quads found nothing to change. Thedlfield is the other half of the result: the page URL travelled with the hit, address and all. - Step 4.
Send three more hits from a client whose address family you choose: one over IPv4, one over IPv6, one through a proxy header.
curl -s -o /dev/null -w '%{http_code} %{remote_ip}\n' "http://127.0.0.1:9634/collect?tid=T-TEST&cid=c-91f2&aip=1&dl=/pricing" --next -s -o /dev/null -w '%{http_code} %{remote_ip}\n' "http://[::1]:9634/collect?tid=T-TEST&cid=c-91f2&aip=1&dl=/pricing" --next -s -o /dev/null -w '%{http_code} %{remote_ip}\n' -H "X-Forwarded-For: 203.0.113.47" "http://127.0.0.1:9634/collect?tid=T-TEST&cid=c-91f2&aip=1&dl=/pricing"200 127.0.0.1 200 ::1 200 127.0.0.1 - Step 5.
Read the four stored rows together.
cat collector.log{"cid":"c-91f2","aip":"1","peer":"::1","family":"IPv6","stored":"::1","xff":null,"dl":"http://127.0.0.1:9635/pricing?plan=team&email=dana.quill%40example.invalid"} {"cid":"c-91f2","aip":"1","peer":"127.0.0.1","family":"IPv4","stored":"127.0.0.0","xff":null,"dl":"/pricing"} {"cid":"c-91f2","aip":"1","peer":"::1","family":"IPv6","stored":"::1","xff":null,"dl":"/pricing"} {"cid":"c-91f2","aip":"1","peer":"127.0.0.1","family":"IPv4","stored":"127.0.0.0","xff":"203.0.113.47","dl":"/pricing"}Three findings in four rows. The IPv4 rows lost their last octet and the IPv6 rows kept everything, from one function. The fourth row stored a shortened peer address next to
203.0.113.47in full, because the proxy header was logged as a separate field. And every row carries the samecid,c-91f2, so the four hits are one linked session whatever the address field holds. - Step 6.
Run the truncation on its own, against addresses of both families, and count what each mask hides. Save as
anonymize.mjs.node anonymize.mjs127.0.0.1 | naive: 127.0.0.0 | by family: 127.0.0.0 ::ffff:127.0.0.1 | naive: ::ffff:127.0.0.0 | by family: 127.0.0.0 ::1 | naive: ::1 | by family: 0000:0000:0000:: 2001:db8:1c0:2::a1 | naive: 2001:db8:1c0:2::a1 | by family: 2001:0db8:01c0:: candidates behind /24: 256 candidates behind /48: 1208925819614629174706176The documentation address
2001:db8:1c0:2::a1came back untouched from the shipped version. An IPv4 address masked to a /24 hides one of 256 candidates; the same idea applied to IPv6 has to work on bits rather than on the last group, and the script that produced this is below.// anonymize.mjs const expand6 = (a) => { const [head, tail = ''] = a.split('::'); const h = head ? head.split(':') : [], t = tail ? tail.split(':') : []; return [...h, ...Array(8 - h.length - t.length).fill('0'), ...t].map((g) => g.padStart(4, '0')); }; const naive = (ip) => ip.replace(/\.\d+$/, '.0'); const byFamily = (ip) => { const a = ip.startsWith('::ffff:') ? ip.slice(7) : ip; return a.includes('.') ? a.replace(/\.\d+$/, '.0') : expand6(a).slice(0, 3).join(':') + '::'; }; for (const ip of ['127.0.0.1', '::ffff:127.0.0.1', '::1', '2001:db8:1c0:2::a1']) { console.log(ip.padEnd(18), '| naive:', naive(ip).padEnd(18), '| by family:', byFamily(ip)); } console.log('candidates behind /24:', 2 ** 8); console.log('candidates behind /48:', (2n ** 80n).toString());
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| stored shorter than peer on every row | The truncation runs on this path | Repeat over both families before calling it done. |
| family IPv6 and stored equal to peer | The code handles dotted quads only | Mask by bits, as in the byFamily column of step 6. |
| A full address in xff next to a shortened peer | The proxy header was stored separately | Truncate the header the same way, or stop storing it. |
| The same cid across rows | The rows are one session regardless of the address | Address truncation is not anonymity while a stable id is stored. |
Common mistakes
What to check next
- How to check for pii in urls: the
dlfield showed a page URL travelling with the hit, which this check finds at the source. - How to check referer header: the other route by which a page URL reaches a collector.
- How to check which third parties receive data: every origin a page sends a request to, not only the one you configured.
- How to check for tracking pixels: finds the image requests that carry hits off-site.
- How to check logs for pii: the same reading, applied to what your own services write.
FAQ
Are your IP addresses anonymized?
Answer it from the stored row, not the request. Step 5 shows four hits that all asked for anonymization and two rows that kept the address in full, so the parameter and the result disagreed in the same log file.
How do I verify IP anonymization is enabled?
Point the tag at a collector you control, send hits over IPv4 and IPv6, and read the log. If you cannot change the endpoint, the check has to be done on the platform's own export instead.
Does Google Analytics anonymize IP addresses?
That question is about a vendor's back end, and no client-side observation answers it. What you can verify yourself is any hop you own: a server-side tagging endpoint, a proxy, a self-hosted collector, or the export a platform gives you.
Is a truncated IP address personal data?
Treat it as a risk reduction, not a guarantee. A /24 leaves 256 candidate addresses, and the row usually also carries a stable client id and a page URL. Whether the record identifies a person is decided by the whole row.
Why did the browser hit arrive over IPv6?
Chrome resolved localhost to ::1 and connected there, while curl to 127.0.0.1 used IPv4. The same URL can reach one collector by either family, which is the point step 4 makes deliberate.
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