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

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'));
// 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();
}

Steps

  1. Step 1.

    Start the collector and the page. Both listeners share one handler, so IPv4 and IPv6 hits run the same code.

    node collector.mjs
    
    collector http://127.0.0.1:9634
    collector http://[::1]:9634
    site      http://127.0.0.1:9635
  2. 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
  3. 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=1 and the address was stored whole. Chrome resolved localhost to ::1, the request arrived over IPv6, and a truncation written for dotted quads found nothing to change. The dl field is the other half of the result: the page URL travelled with the hit, address and all.

  4. 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
  5. 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.47 in full, because the proxy header was logged as a separate field. And every row carries the same cid, c-91f2, so the four hits are one linked session whatever the address field holds.

  6. 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.mjs
    
    127.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: 1208925819614629174706176

    The documentation address 2001:db8:1c0:2::a1 came 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

Sign: Truncation is tested from one machine, passes, and ships.Cause: The regex in step 3 zeroes the last octet of a dotted quad and leaves every IPv6 address untouched. The browser hit arrived over IPv6 because Chrome resolved localhost to ::1, so the one visitor most likely to be tested is the one whose address was kept in full.
Sign: The request carries aip=1, so the check is recorded as passed.Cause: A parameter is what the client asked for. All four rows in step 5 carried aip=1 and two of them stored the address whole. Only the receiving end knows what was written, which is why this check reads the log rather than the request.
Sign: The peer address is anonymized and the proxy header is not.Cause: The fourth row of step 5 holds stored 127.0.0.0 next to xff 203.0.113.47. Behind a load balancer the client address arrives in a header, so a function applied to the socket address alone shortens the wrong value.
Sign: An anonymized address is treated as an anonymous record.Cause: Every row in step 5 carries the same client id, and the first also carries the page URL with an address in its query string. A masked IP beside a stable identifier narrows nothing, which is the question a data review asks.

What to check next

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.

intermediate12 minpublished updated Maks Verny