How to check for pii in urls
Load one page whose query string carries a synthetic email and reset token, then read three places: the server access log, the Referer arriving at a second origin, and the browser profile history. In the run below, one navigation wrote email=dana.quill%40example.invalid into all three, and the fragment into one.
Why check this
Run this at staging sign-off, and again whenever a new parameter appears in a URL: a share link, a password reset mail, a redirect back from a payment provider, a campaign tag on a landing page. The reason this is a check and not a style opinion is the number of copies. A query string is read by the server, by every third-party origin the page loads, by the browser on disk, and by whatever ships the logs onward.
The failure it prevents is a reset token that outlives its use: readable in an access log, handed to an analytics host that never needed it, left in the history of a shared machine. None of those copies appears in the application database, so nothing in the product tells you they exist.
Prerequisites
- Node 22 and Chrome 152, on a shell that is not sandboxed away from
127.0.0.1. - Synthetic values only. The address, order number and token below were invented for this page.
- Two origins: the site under test on
127.0.0.1:9630, and a receiver onlocalhost:9631standing in for a third-party tag. Save aspii-servers.mjs.
import { createServer } from 'node:http';
import { appendFileSync } from 'node:fs';
const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
const stamp = () => new Date().toISOString();
// The site under test. Logs in the shape a reverse proxy logs: path and query, together.
createServer((req, res) => {
appendFileSync('site-access.log',
`${req.socket.remoteAddress} [${stamp()}] "GET ${req.url}" ref="${req.headers.referer ?? '-'}"\n`);
if (req.url.startsWith('/order/confirm')) {
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'referrer-policy': 'no-referrer-when-downgrade',
});
res.end('<!doctype html><html><head><title>Order confirmed</title></head><body>'
+ '<h1>Order confirmed</h1>'
+ '<img src="http://localhost:9631/px.gif" alt="" width="1" height="1">'
+ '</body></html>');
} else {
res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
}
}).listen(9630, '127.0.0.1', () => console.log('site http://127.0.0.1:9630'));
// A second origin, standing in for any third-party tag the page loads.
createServer((req, res) => {
appendFileSync('receiver.log',
`[${stamp()}] "GET ${req.url}" referer=${req.headers.referer ?? '(none)'}\n`);
res.writeHead(200, { 'content-type': 'image/gif' }).end(GIF);
}).listen(9631, 'localhost', () => console.log('receiver http://localhost:9631'));
- A visitor that reports what the browser fetched and what it wrote to disk. Save as
visit.mjstwo directories belowscripts/browser/session.mjs, which drives the installed Chrome throughpuppeteer-core.node:sqliteprints an experimental warning that is trimmed from the outputs below.
import { open } from '../../scripts/browser/session.mjs';
import { copyFileSync, rmSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
const s = await open();
try {
console.log('chrome :', await s.browser.version());
await s.goto(process.argv[2]);
console.log('location.href :', await s.page.evaluate(() => location.href));
for (const r of s.requests) console.log('request :', r.status, r.url);
// Chrome holds the history database open, so copy it out after the browser exits.
const dir = s.browser.process().spawnargs.find((a) => a.startsWith('--user-data-dir=')).slice(16);
await s.browser.close();
copyFileSync(`${dir}/Default/History`, 'History.copy');
const db = new DatabaseSync('History.copy', { readOnly: true });
for (const r of db.prepare('SELECT url FROM urls ORDER BY id').all()) console.log('history row :', r.url);
db.close();
rmSync(dir, { recursive: true, force: true });
} finally {
await s.close().catch(() => {});
}
- Stop the servers afterwards:
netstat -ano | grep 9630, thenpowershell -Command "Stop-Process -Id <pid> -Force". - The browser figures are one capture on one machine on 2026-09-12.
Steps
- Step 1.
Start both origins. The two log files are written next to the script.
node pii-servers.mjssite http://127.0.0.1:9630 receiver http://localhost:9631 - Step 2.
Visit the page with the identifiers in the query string, the way a confirmation mail links to it.
node visit.mjs "http://127.0.0.1:9630/order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21#receipt"… chrome : Chrome/152.0.7977.76 location.href : http://127.0.0.1:9630/order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21#receipt request : 200 http://127.0.0.1:9630/order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21#receipt request : 200 http://localhost:9631/px.gif request : 404 http://127.0.0.1:9630/favicon.ico history row : http://127.0.0.1:9630/order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21#receiptThe history row is a file on disk, and it keeps the fragment. So does Chrome's own record of the request, which is why the browser is a poor witness for what left the machine.
- Step 3.
Read what the two servers received from that single navigation.
tail -n +1 site-access.log receiver.log==> site-access.log <== 127.0.0.1 [2026-09-12T08:01:19.135Z] "GET /order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21" ref="-" 127.0.0.1 [2026-09-12T08:01:19.205Z] "GET /favicon.ico" ref="http://127.0.0.1:9630/order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21" ==> receiver.log <== [2026-09-12T08:01:19.188Z] "GET /px.gif" referer=http://127.0.0.1:9630/order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21Three copies from one navigation. The request line holds the address and the token. The second line repeats both in its
ref=field, because a same-origin subresource is sent the whole URL. The receiver is a different origin and it was sent the same string. - Step 4.
Move the token after the
#and visit again. This is the usual first fix, and it is worth measuring rather than assuming.node visit.mjs "http://127.0.0.1:9630/order/confirm?order=A-4471#reset_token=rst_7f3c9a21"… location.href : http://127.0.0.1:9630/order/confirm?order=A-4471#reset_token=rst_7f3c9a21 request : 200 http://127.0.0.1:9630/order/confirm?order=A-4471#reset_token=rst_7f3c9a21 request : 200 http://localhost:9631/px.gif request : 404 http://127.0.0.1:9630/favicon.ico history row : http://127.0.0.1:9630/order/confirm?order=A-4471#reset_token=rst_7f3c9a21 - Step 5.
Read both logs again. The first lines of each file are the previous run, so compare them with what the second run added.
tail -n +1 site-access.log receiver.log==> site-access.log <== 127.0.0.1 [2026-09-12T08:01:19.135Z] "GET /order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21" ref="-" 127.0.0.1 [2026-09-12T08:01:19.205Z] "GET /favicon.ico" ref="http://127.0.0.1:9630/order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21" 127.0.0.1 [2026-09-12T08:01:28.217Z] "GET /order/confirm?order=A-4471" ref="-" 127.0.0.1 [2026-09-12T08:01:28.259Z] "GET /favicon.ico" ref="http://127.0.0.1:9630/order/confirm?order=A-4471" ==> receiver.log <== [2026-09-12T08:01:19.188Z] "GET /px.gif" referer=http://127.0.0.1:9630/order/confirm?order=A-4471&email=dana.quill%40example.invalid&reset_token=rst_7f3c9a21 [2026-09-12T08:01:28.236Z] "GET /px.gif" referer=http://127.0.0.1:9630/order/confirm?order=A-4471The token reached neither server. A fragment is never put on the wire, which is why an identifier after
#is a different risk from one in the query: it stops at the browser, where the history row in step 4 still holds it and any script on the page can readlocation.hashand send it onward. - Step 6.
Inventory the parameter names the log now holds. This is the form the check takes against a real log file.
grep -o -E '[?&][A-Za-z_]+=' site-access.log | tr -d '?&=' | sort | uniq -c | sort -rn4 order 2 reset_token 2 emailTwo navigations produced four
ordercounts, because each was logged twice: once in the request line, once inside theref=field of the next request. Read the names, not the totals.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| An identifier in the request line of the access log | Every copy of that log holds it, including shipped copies | Move the value to a POST body or a server-side session key. |
| The same identifier in a Referer at another origin | A third party was handed it by the browser | Set a stricter referrer policy and take the value out of the URL. |
| The value in ref= on a same-origin line | Same-origin requests are sent the full URL under every common policy | Policy alone does not help here. The URL has to change. |
| Nothing in either log, but the history row holds it | The value is in the fragment | Treat it as exposed on a shared machine and to any script on the page. |
Common mistakes
What to check next
- How to check referer header: measures how much of that URL each
Referrer-Policyvalue lets the browser send onward. - How to check referrer-policy header: reads the policy a site declares, from the wire.
- How to check if analytics ip is anonymized: the analytics hit carries the page URL, so a truncated address next to it settles little.
- How to check logs for pii: the same scan over the bodies and fields a service logs, rather than the URL.
- Access log format: which fields your log format writes, and how many copies each one ends up in.
FAQ
Is an identifier in the path safer than one in the query string?
No. The access log, the Referer and the history row carry the whole path and query together. Step 3 shows both arriving in all three places from one navigation.
Does the fragment count as private?
It is private from the servers and not from the browser. Step 5 shows it missing from both logs, and step 4 shows it stored in the history database, where a script can also read it through location.hash.
How do I run this against a site I cannot instrument?
Load the target in Chrome with DevTools open and read the Referer of each third-party request in the Network panel. The access log half of the check needs the server.
Which parameter names should the scan flag?
Anything that identifies a person or authorises an action: address, phone, name, token, session id, invoice, order reference. Flag the name first, then sample the values, as in step 6.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76GNU grep 3.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
intermediate10 minpublished updated Maks Verny