How to check logs for pii
Send requests carrying known synthetic values, then scan every file the service writes, not only the application log. grep -n -o -E finds the addresses and a key-aware Node pass names the field that held each one. In the run below the same address was redacted under user.email and printed in full under ticket.contact.email.
Why check this
This runs on staging before sign-off, and again after any change to a logger, a serializer or a log shipper. A redaction list is written against the payload shape of the day it was added, and it goes stale silently: nothing errors when a field moves, it only stops being covered.
The concrete failure it prevents is a support ticket body reaching a third-party log platform with a customer address and a card number in it, under a field name nobody put on the redaction list. Once shipped, that data is in someone else's retention policy, not yours.
Prerequisites
- Node 22 and
npm i pino. The pino redaction page documents the path syntax used below. - A local target that writes both kinds of log. Save this as
pii-service.mjsand start it in its own directory, since it writesapp.logandaccess.lognext to itself.
// pii-service.mjs Node 22. npm i pino
import { createServer } from 'node:http';
import { appendFileSync } from 'node:fs';
import pino from 'pino';
const app = pino(
{ redact: ['user.email'], timestamp: pino.stdTimeFunctions.isoTime },
pino.destination({ dest: './app.log', sync: true })
);
const access = (req, status) =>
appendFileSync('./access.log',
`${new Date().toISOString()} 127.0.0.1 "${req.method} ${req.url}" ${status}\n`);
const body = async (req) => { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; };
createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
let status = 404;
if (url.pathname === '/orders') {
const b = await body(req);
app.info({ user: { email: b.email }, payment: { card: b.card } }, 'order created');
status = 201;
} else if (url.pathname === '/support') {
const b = await body(req);
app.info({ ticket: { contact: { email: b.email }, text: b.text } }, 'ticket opened');
status = 201;
} else if (url.pathname === '/profile') {
app.info({ user: { email: url.searchParams.get('email') } }, 'profile read');
status = 200;
}
res.writeHead(status, { 'content-type': 'application/json' });
res.end(status === 404 ? '{}' : '{"ok":true}');
access(req, status);
}).listen(8317, '127.0.0.1', () => console.log('pii-service on 127.0.0.1:8317'));
- Seed values invented for this page, so nothing here belongs to a person: address
dana.roswell@ordermail.test, card4539598123456787(Luhn-valid, issued by nobody), phone+1 555 0147.
Steps
- Step 1.
Start the target. Pick a port nothing else on your machine is using.
node pii-service.mjspii-service on 127.0.0.1:8317 - Step 2.
Drive three requests that carry the seed values through three different code paths: a JSON body, a free-text field, and a query string.
curl -s -X POST http://127.0.0.1:8317/orders -H 'content-type: application/json' \ -d '{"email":"dana.roswell@ordermail.test","card":"4539598123456787"}' \ --next -s -X POST http://127.0.0.1:8317/support -H 'content-type: application/json' \ -d '{"email":"dana.roswell@ordermail.test","text":"card 4539598123456787 was declined, call +1 555 0147"}' \ --next -s 'http://127.0.0.1:8317/profile?email=dana.roswell@ordermail.test&plan=pro'{"ok":true}{"ok":true}{"ok":true} - Step 3.
Read the application log.
cat app.log{"level":30,"time":"2026-09-12T07:27:49.547Z","pid":44816,"hostname":"kharkivlad","user":{"email":"[Redacted]"},"payment":{"card":"4539598123456787"},"msg":"order created"} {"level":30,"time":"2026-09-12T07:27:49.550Z","pid":44816,"hostname":"kharkivlad","ticket":{"contact":{"email":"dana.roswell@ordermail.test"},"text":"card 4539598123456787 was declined, call +1 555 0147"},"msg":"ticket opened"} {"level":30,"time":"2026-09-12T07:27:49.551Z","pid":44816,"hostname":"kharkivlad","user":{"email":"[Redacted]"},"msg":"profile read"}Line 1 carries a
[Redacted]token and a card number in the clear on the same line. A redaction marker is evidence about one field, never about the line. - Step 4.
Search both files for the seed address. Name the access log explicitly: it is written by different code and no logger rule applies to it.
grep -n -o -E "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" app.log access.logapp.log:2:dana.roswell@ordermail.test access.log:3:dana.roswell@ordermail.testTwo hits for one address. The application log was redacted on the route that used
user.emailand not on the route that usedticket.contact.email. The access log kept the query string of the third request whole. - Step 5.
Run a key-aware pass, so each hit is reported with the field that held it. Save this as
scan-pii.mjs.// scan-pii.mjs usage: node scan-pii.mjs app.log access.log import { readFileSync } from 'node:fs'; const RULES = [ ['email', /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g], ['card', /\b(?:\d[ -]?){13,19}\b/g], ['phone', /\+\d[\d ()-]{7,}\d/g], ]; const luhn = (s) => { const d = [...s.replace(/\D/g, '')].reverse().map(Number); return d.length >= 13 && d.reduce((a, x, i) => a + (i % 2 ? (x * 2 > 9 ? x * 2 - 9 : x * 2) : x), 0) % 10 === 0; }; const walk = (v, path, out) => { if (v && typeof v === 'object') { for (const k of Object.keys(v)) walk(v[k], path ? `${path}.${k}` : k, out); return; } for (const [name, re] of RULES) for (const hit of String(v).match(re) ?? []) { if (name === 'card' && !luhn(hit)) continue; out.push([name, path, hit]); } }; for (const file of process.argv.slice(2)) { readFileSync(file, 'utf8').split('\n').filter(Boolean).forEach((line, i) => { const out = []; try { walk(JSON.parse(line), '', out); } catch { walk(line, 'raw', out); } for (const [name, path, hit] of out) console.log(`${file}:${i + 1} ${name.padEnd(5)} ${path.padEnd(20)} ${hit}`); }); }node scan-pii.mjs app.log access.logapp.log:1 card payment.card 4539598123456787 app.log:2 email ticket.contact.email dana.roswell@ordermail.test app.log:2 card ticket.text 4539598123456787 app.log:2 phone ticket.text +1 555 0147 access.log:3 email raw dana.roswell@ordermail.testFive findings, four field names, and the Luhn filter kept timestamps and process ids out of the card rows.
ticket.textis the row that matters: two values arrived inside a message a user typed, where no field-level rule would have looked. - Step 6.
Measure how deep the redaction list reaches. Save this as
redact-paths.mjsand run it.// redact-paths.mjs what a pino redact list does and does not cover import pino from 'pino'; const payload = { user: { email: 'a@ordermail.test' }, ticket: { contact: { email: 'a@ordermail.test' } }, request: { body: { user: { email: 'a@ordermail.test' } } }, }; for (const paths of [['user.email'], ['*.email'], ['*.*.email'], ['user.email', '*.*.email']]) { const out = []; pino({ redact: paths, base: null, timestamp: false }, { write: (c) => out.push(c) }).info(payload); console.log(JSON.stringify(paths) + '\n ' + out.join('').trim()); }["user.email"] {"level":30,"user":{"email":"[Redacted]"},"ticket":{"contact":{"email":"a@ordermail.test"}},"request":{"body":{"user":{"email":"a@ordermail.test"}}}} ["*.email"] {"level":30,"user":{"email":"[Redacted]"},"ticket":{"contact":{"email":"a@ordermail.test"}},"request":{"body":{"user":{"email":"a@ordermail.test"}}}} ["*.*.email"] {"level":30,"user":{"email":"a@ordermail.test"},"ticket":{"contact":{"email":"[Redacted]"}},"request":{"body":{"user":{"email":"a@ordermail.test"}}}} ["user.email","*.*.email"] {"level":30,"user":{"email":"[Redacted]"},"ticket":{"contact":{"email":"[Redacted]"}},"request":{"body":{"user":{"email":"a@ordermail.test"}}}}*stands for exactly one key, not for any depth.*.emailcovers depth two and leaves depth three alone,*.*.emailswaps which one is covered, and the two together still missrequest.body.user.email. Every depth a payload can reach needs its own entry.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A hit in access.log only | The value travelled in the URL, where the logger never sees it | Move the parameter into the body or the path, and stop logging the query string. |
| A hit under a key the redact list does not name | The payload shape moved and the list did not | Add the path, then add a test that fails when a new shape appears. |
| A hit in a free-text field | A user typed it, or a service copied a body into a message | Field-level redaction cannot fix this. Match on the value, not on the key. |
| [Redacted] on one key, raw data on the next | The list covers that one field | Read the whole line before calling it clean. |
Common mistakes
What to check next
- Sensitive data in logs: the same scan run for passwords, tokens and header dumps.
- Access log format: which fields the access log should carry, and which turn it into a store of user data.
- How to check for pii in urls: catches the parameter before it ever reaches a log file.
- Json logging format: a structured log is what makes the key-aware pass in step 5 possible.
FAQ
What counts as PII in a log line?
Anything that identifies a person on its own or combined with the rest of the line: address, phone, full name, national id, card number, precise location, and the client IP address, which several jurisdictions treat as personal data. Session and user ids count when your own systems can resolve them to a person.
How does PII masking in logs work?
A logger rewrites named paths before serialisation, as pino's redact does. It is a key-based mechanism, so it covers the fields it was told about at the depth it was told about. Free text and values that arrive under a new key pass through untouched.
Can I check for PII in logs without a scanner?
For one known value, yes. Seed a request with a synthetic address and grep the log files for it, as step 4 does. That proves a route, not a service. Anything wider needs a pass over every field, because you cannot grep for values you have not seen.
Where does PII enter logs when nobody logs it on purpose?
Query strings copied into an access line, request bodies attached to error objects, free-text fields, and any place a whole object is passed to the logger instead of named fields. All four cases print data that no line of code names.
Verified
Verified by Maks Vernynode 22.23.2pino 9.14.0curl 8.21.0grep GNU 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
intermediate12 minpublished updated Maks Verny