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

// 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

  1. Step 1.

    Start the service with the list a team writes first: the field names everyone remembers.

    node secrets-service.mjs
    
    secrets-service on 127.0.0.1:8318, redact: password req.headers.authorization apiKey
  2. 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"}
  3. 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 authorization header and prints the session cookie beside it, because no path named cookie. Line 2 shows why a bare password entry is not enough: the value arrived at body.password, and the list covers the top level only.

  4. 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 named req.headers.authorization, and this copy lives at err.config.headers.authorization.

  5. 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)"; done
    
    Coral-Anchor-7731                  1
    sk_live_09ed53b8d7c3c0ff1f0d4d93   1
    sid=7c2f41ab9e0d4c6f8a13           2
    wtbGGpw9uWAOBYe4hrV72laKd9XYx8ar   1

    Four secrets, four non-zero counts, from a service whose redaction list was doing its job on the one path it named.

  6. Step 6.

    Decode the prefix that line 3 called safe.

    node -e "console.log(Buffer.from('eyJhbGciOiJI','base64').toString())"
    
    {"alg":"H

    Twelve characters of a JWT are its header, not an opaque handle. With tokenLen beside it, that line states the signing family and the exact length of the credential.

  7. 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.mjs
    
    secrets-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
  8. 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)"; done
    
    Coral-Anchor-7731                  0
    sk_live_09ed53b8d7c3c0ff1f0d4d93   0
    sid=7c2f41ab9e0d4c6f8a13           0
    wtbGGpw9uWAOBYe4hrV72laKd9XYx8ar   0

    Four zeros, and line 3 is unchanged: tokenPrefix and tokenLen are 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

Sign: The header is redacted in the request log and printed in full from an error.Cause: A client library attaches the request it made to the error it throws, and the copy sits at err.config.headers.authorization. The rule named req.headers.authorization, so the second copy is a different path and is written whole, along with the cookie.
Sign: A field name is on the redaction list and the value is still in the file.Cause: pino matches paths, not names. The entry password covers a top-level password key, and the value arrived at body.password. Nothing warns about the miss, because a path that matches nothing is not an error.
Sign: A log line prints a token prefix and its length and is treated as safe.Cause: Twelve characters of a JWT decode to the start of its header, and this run printed tokenLen 120 next to it. For keys with a live or test prefix, the same line states which environment the credential belongs to.
Sign: The canary run passes and secrets appear in production logs anyway.Cause: The run exercised one route at one log level. Error paths, debug level and third-party log shippers write lines that a single happy-path request never reaches. Send the failing variants too, and run the count at the level production uses.

What to check next

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.

intermediate10 minpublished updated Maks Verny