Sensitive data in logs
Send one request carrying canary values you generated, then count each value in the log file. grep -c -F per secret turns the question into a number that a build can assert on. In the run below a redaction list of three paths left the password, the API key, the session cookie and the bearer token in the file.
Why check this
Run this after any change to the logger, to an error handler, or to a client library that attaches the request it made to the error it throws. Those three places write most of the secrets that reach log storage, and none of them is where the redaction list was aimed.
The failure it prevents is a live API key sitting in a log platform that a support contractor can search. Rotating the key is the cheap part. Finding every index and backup that holds it is not.
Prerequisites
- Node 22 and
npm i pino. Path syntax for the redaction list is on the pino redaction page. - Canary values you generate for the test run, never a real credential from your machine. This page uses password
Coral-Anchor-7731, keysk_live_09ed53b8d7c3c0ff1f0d4d93, cookiesid=7c2f41ab9e0d4c6f8a13and a bearer token signed by nobody. - A local target. Save this as
secrets-service.mjs. The redaction list comes from an environment variable so the same file can run with a wrong list and a right one.
// secrets-service.mjs Node 22. npm i pino
import { createServer } from 'node:http';
import pino from 'pino';
const redact = (process.env.REDACT ?? 'password,req.headers.authorization,apiKey').split(',');
const app = pino(
{ redact, timestamp: pino.stdTimeFunctions.isoTime },
pino.destination({ dest: './secrets.log', sync: true })
);
const body = async (req) => { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; };
createServer(async (req, res) => {
app.info({ req: { method: req.method, url: req.url, headers: req.headers } }, 'request received');
if (req.url === '/login') {
const b = await body(req);
app.info({ body: b }, 'login attempt');
const token = (req.headers.authorization ?? '').slice(7);
app.info({ tokenPrefix: token.slice(0, 12), tokenLen: token.length }, 'token accepted');
if (!b.otp) {
const err = new Error('otp missing');
err.config = { url: req.url, headers: req.headers };
app.error({ err }, 'login failed');
res.writeHead(400, { 'content-type': 'application/json' });
res.end('{"error":"otp_missing"}');
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"ok":true}');
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end('{}');
}).listen(8318, '127.0.0.1', () => console.log('secrets-service on 127.0.0.1:8318, redact:', redact.join(' ')));
Steps
- Step 1.
Start the service with the list a team writes first: the field names everyone remembers.
node secrets-service.mjssecrets-service on 127.0.0.1:8318, redact: password req.headers.authorization apiKey - Step 2.
Send one login that carries all four canaries: two in the body, two in headers.
curl -s -X POST http://127.0.0.1:8318/login \ -H 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzQ0NzEiLCJpc3MiOiJoMmNoZWNrLWRlbW8ifQ.wtbGGpw9uWAOBYe4hrV72laKd9XYx8ar' \ -H 'cookie: sid=7c2f41ab9e0d4c6f8a13' -H 'content-type: application/json' \ -d '{"email":"dana.roswell@ordermail.test","password":"Coral-Anchor-7731","apiKey":"sk_live_09ed53b8d7c3c0ff1f0d4d93"}'{"error":"otp_missing"} - Step 3.
Read the first three lines the request produced.
head -3 secrets.log{"level":30,"time":"2026-09-12T07:32:05.437Z","pid":44320,"hostname":"kharkivlad","req":{"method":"POST","url":"/login","headers":{"host":"127.0.0.1:8318","user-agent":"curl/8.21.0","accept":"*/*","authorization":"[Redacted]","cookie":"sid=7c2f41ab9e0d4c6f8a13","content-type":"application/json","content-length":"114"}},"msg":"request received"} {"level":30,"time":"2026-09-12T07:32:05.440Z","pid":44320,"hostname":"kharkivlad","body":{"email":"dana.roswell@ordermail.test","password":"Coral-Anchor-7731","apiKey":"sk_live_09ed53b8d7c3c0ff1f0d4d93"},"msg":"login attempt"} {"level":30,"time":"2026-09-12T07:32:05.440Z","pid":44320,"hostname":"kharkivlad","tokenPrefix":"eyJhbGciOiJI","tokenLen":120,"msg":"token accepted"}Line 1 redacts the
authorizationheader and prints the session cookie beside it, because no path namedcookie. Line 2 shows why a barepasswordentry is not enough: the value arrived atbody.password, and the list covers the top level only. - Step 4.
Read what the error object carried. A client library that attaches the request it made turns one thrown error into a full header dump.
node -e "const l=require('fs').readFileSync('secrets.log','utf8').trim().split('\n').map(JSON.parse);console.log(JSON.stringify(l[3].err.config,null,2))"{ "url": "/login", "headers": { "host": "127.0.0.1:8318", "user-agent": "curl/8.21.0", "accept": "*/*", "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzQ0NzEiLCJpc3MiOiJoMmNoZWNrLWRlbW8ifQ.wtbGGpw9uWAOBYe4hrV72laKd9XYx8ar", "cookie": "sid=7c2f41ab9e0d4c6f8a13", "content-type": "application/json", "content-length": "114" } }The same header is
[Redacted]on line 1 and whole on line 4. The rule namedreq.headers.authorization, and this copy lives aterr.config.headers.authorization. - Step 5.
Count each canary. This is the form a pipeline can run, because it produces a number per secret rather than a wall of log.
for s in 'Coral-Anchor-7731' 'sk_live_09ed53b8d7c3c0ff1f0d4d93' 'sid=7c2f41ab9e0d4c6f8a13' 'wtbGGpw9uWAOBYe4hrV72laKd9XYx8ar'; do printf '%-34s %s\n' "$s" "$(grep -c -F "$s" secrets.log)"; doneCoral-Anchor-7731 1 sk_live_09ed53b8d7c3c0ff1f0d4d93 1 sid=7c2f41ab9e0d4c6f8a13 2 wtbGGpw9uWAOBYe4hrV72laKd9XYx8ar 1Four secrets, four non-zero counts, from a service whose redaction list was doing its job on the one path it named.
- Step 6.
Decode the prefix that line 3 called safe.
node -e "console.log(Buffer.from('eyJhbGciOiJI','base64').toString())"{"alg":"HTwelve characters of a JWT are its header, not an opaque handle. With
tokenLenbeside it, that line states the signing family and the exact length of the credential. - Step 7.
Restart with a list that names every path the run exposed. Remove the log first: pino holds the file open, so deleting it under a running process leaves the writes going to a handle you cannot read.
rm -f secrets.log && REDACT='req.headers.authorization,req.headers.cookie,body.password,body.apiKey,err.config.headers.authorization,err.config.headers.cookie' node secrets-service.mjssecrets-service on 127.0.0.1:8318, redact: req.headers.authorization req.headers.cookie body.password body.apiKey err.config.headers.authorization err.config.headers.cookie - Step 8.
Repeat the request from step 2, then count again.
for s in 'Coral-Anchor-7731' 'sk_live_09ed53b8d7c3c0ff1f0d4d93' 'sid=7c2f41ab9e0d4c6f8a13' 'wtbGGpw9uWAOBYe4hrV72laKd9XYx8ar'; do printf '%-34s %s\n' "$s" "$(grep -c -F "$s" secrets.log)"; doneCoral-Anchor-7731 0 sk_live_09ed53b8d7c3c0ff1f0d4d93 0 sid=7c2f41ab9e0d4c6f8a13 0 wtbGGpw9uWAOBYe4hrV72laKd9XYx8ar 0Four zeros, and line 3 is unchanged:
tokenPrefixandtokenLenare still written, since no path names them. Redaction fixed the fields; the field that was designed to be partial has to be deleted.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A count above zero for any canary | That value reaches log storage today | Add the exact path, then rerun the count and keep it in the build. |
| [Redacted] on one line, the same value raw on another | The rule is bound to a path, not to a value | Name every path the value can reach, including the one inside an error. |
| A prefix or a length beside a redacted token | Partial disclosure | Drop the field. A prefix identifies the token family; a length narrows the search. |
| All counts zero | Those four values are covered on that route | Send other routes. A canary run proves the paths it exercised, nothing wider. |
Common mistakes
What to check next
- How to check logs for pii: the same file scanned for addresses and card numbers, and the depth limit of a redact path.
- How to check for secrets in a git repository: the other place a canary value survives after it is removed from the code.
- How to check for secrets in environment variables: where the values a test run leaks into logs usually come from.
- Access log format: the second log file, written by different code, that no redaction list covers.
FAQ
How can developers protect sensitive data in logs?
Log named fields rather than whole objects, keep a redaction list for the paths that still carry secrets, and never attach a request or a headers map to an error. Then assert on it: a canary value and a count per secret, run in the build, is the part that keeps working after the next refactor.
How do I mask sensitive data in logs?
A logger rewrites named paths before serialisation, as pino's redact option does. Masking covers the exact paths listed, so a value that moves, or that is copied into an error, arrives unmasked. Treat the list as code that needs a test.
Is a token that prints only its prefix safe to log?
No. Step 6 decoded a twelve-character prefix into the start of a JWT header, and the same line printed the token length. Prefixes also separate live keys from test keys. Log a hash of the token if you need to correlate, never a piece of it.
What should a tester send to prove a secret is not logged?
A generated canary value that exists nowhere else, through the route under test, then a count of that string across every log file the service writes. Zero is the pass. A real credential in a test run creates the incident it was meant to catch.
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
intermediate10 minpublished updated Maks Verny