Skip to content

User-agent redaction in the shared pino config โ€‹

What was this, exactly? โ€‹

Hygiene, not a leak. โ€‹

  • A user-agent is not PII. It carries no phone number, no name, no address. It is a fingerprinting input: a string that narrows which device a request came from, and which combines with other signals to re-identify.
  • It was in the logs because pino-http serializes every request header by default, not because anyone decided to keep it. Retention by default is the whole defect.
  • The operator's framing: "it is moreso we are not capturing PII as much as possible, such as phone numbers, names, etc." Read this as reducing what we hold, not as closing a breach.

Nothing read it, so removing it broke nothing. โ€‹

  • A repo-wide grep across all seven services for any read of the user-agent header returns zero hits. No route, no middleware, no rate limiter.
  • The browser reads its own navigator.userAgent in apps/web/src/lib/deviceOptimization.js, locationPermission.js and debugMobileAuth.js. That is a page inspecting itself locally and transmitting nothing. Out of scope, unchanged.

What changed? โ€‹

One path in one shared file, inherited by all seven services. โ€‹

  • packages/shared/services/index.js gains 'req.headers["user-agent"]' in PINO_REDACT_CONFIG.paths.
  • Every service passes that same object to pinoHttp, so the single entry covers auth, venues, lanterns, analytics, merchants, assistant and docs. services/api/auth/src/index.js:70 is the one that matters most, since it handles phone sign in.
  • The redacted value becomes [REDACTED], the config's existing censor, so a log line keeps its shape and a grep for the field still finds it.

How was it proven? โ€‹

The unit test proves the config scrubs; the live run proves the service does. โ€‹

  • A passing unit test is not proof that a running service redacts, so both were done.

Test: packages/shared/__tests__/services/pinoRedact.test.js โ€‹

  • Four cases, run through a real pino logger built with PINO_REDACT_CONFIG and a memory sink, not an assertion about the array's contents alone.
  • Confirmed meaningful: replaying the same payload through the pre-fix path list emits the probe user-agent in full, so the test fails against the old config.
  • Full shared suite after the change: 180 passed, 14 files.

Live: auth-api and lanterns-api, run locally on ports 8194 and 8195 โ€‹

  • Started with PORT=<n> node --env-file=.env.local src/index.js on non-default ports, so neither collided with the two other lanes sharing this checkout.
  • Probe request carried User-Agent: LANTERNUAPROBE1004/1.0 (Pixel 8; Android 14).

Before, the auth-api log line:

json
"headers": {
  "host": "127.0.0.1:8194",
  "accept": "*/*",
  "user-agent": "LANTERNUAPROBE1004/1.0 (Pixel 8; Android 14)",
  "referer": "https://example.test/probe-referer",
  "x-forwarded-for": "203.0.113.77",
  "accept-language": "en-GB,en;q=0.9",
  "sec-ch-ua-platform": "\"Android\"",
  "authorization": "[REDACTED]"
}

After, same request, same service:

json
"headers": {
  "host": "127.0.0.1:8194",
  "accept": "*/*",
  "user-agent": "[REDACTED]",
  "referer": "https://example.test/probe-referer",
  "x-forwarded-for": "203.0.113.77",
  "accept-language": "en-GB,en;q=0.9",
  "sec-ch-ua-platform": "\"Android\""
}
  • grep -c LANTERNUAPROBE1004 over the whole post-fix log file returns 0, for both services.
  • lanterns-api was run as a second service to confirm the inheritance claim rather than assert it from the import graph.

What does the Cloud Run gap leave open? โ€‹

This issue is HALF closed, and calling it done would be wrong. โ€‹

  • Cloud Run's serving infrastructure writes its own request log to run.googleapis.com%2Frequests with an httpRequest struct. That struct includes userAgent, plus remoteIp, referer, requestUrl, status and latency.
  • No application-side redact config can reach it. It is emitted by the platform, outside the process, after our code has returned. PINO_REDACT_CONFIG is structurally incapable of touching it.
  • So after this change, the raw user-agent is still retained for every request to every deployed service. What changed is that our own log line no longer duplicates it.

What it would actually take. โ€‹

  • A Log Router exclusion on the _Default sink for that log name drops the platform request entries. It is all-or-nothing: exclusions drop whole entries and cannot null one field, so latency and status observability go with it.
  • A scrubbing sink, routing the entries to Pub/Sub or BigQuery, stripping userAgent there, and excluding the originals from _Default. That keeps the observability and is real infrastructure to build and own.
  • Shorter bucket retention on _Default (30 days by default) is minimization rather than removal, and is the cheapest partial step.

Retention is still unverified, and the reason is unchanged. โ€‹

  • gcloud is installed on the dev VM, at /home/mechelle/google-cloud-sdk/bin/gcloud, it is just not on PATH, so which gcloud returns nothing and this document previously read that as absent. Retention and exclusion filters can be read and set from here using the absolute path. (Corrected 2026-08-30; the earlier "not installed" note was wrong, not stale.)

What did the sibling headers show, and what changed? โ€‹

Nothing but the user-agent was changed. Everything below is a proposal. โ€‹

  • pino-http serializes the whole headers object, so every header a client sends lands in the log unless a path redacts it. These were observed in real log lines during the live run, not inferred.
HeaderWhat it isFindingProposal
x-forwarded-forThe client IP chainLoad bearing. Express derives req.ip from it under trust proxy, and req.ip is what the rate limiters and abuse defence key on (packages/shared/middleware/rateLimiter.js:109, services/api/auth/src/routes/phoneOtp.js, appeals.js)Do not redact without a decision. Redaction affects the log line only, not req.ip, so it would not break rate limiting. But an IP is the primary abuse-investigation signal and dropping it from logs is a security tradeoff the operator should make deliberately
x-firebase-appcheckA Firebase App Check attestation tokenThe strongest finding here, and it is a credential rather than a fingerprint. The web app attaches it to essentially every API call (apps/web/src/lib/apiClient.js:49, signupApi.js:63, flash.js:457, plus the auth screens) and it landed in the log in full, verified liveDONE, #1007. Proposed here, then fixed the same day on the operator's call. See What the sibling survey produced
refererThe referring URLEffectively harmless here. All three apps set Referrer-Policy: strict-origin-when-cross-origin (apps/{web,admin,site}/public/_headers), and calls to the Cloud Run services are cross-origin, so a browser sends the origin only. Lantern also uses hash routing, and browsers never send the fragment in a referer regardlessLeave it. It carries no path, no venue id, no route. It is mildly useful for spotting which app called
accept-languageLanguage and region preferenceA fingerprinting input in the same family as the user-agent, and a weak region signal. Nothing reads itCandidate for the same treatment, on the same reasoning. Not applied, because the operator's decision named the user-agent
sec-ch-ua-platform and the sec-ch-ua-* familyUser-Agent Client HintsThe successor to the user-agent string, and the reason a UA-only fix ages badly. Chromium sends sec-ch-ua, sec-ch-ua-mobile and sec-ch-ua-platform by default. Verified live: sec-ch-ua-platform: "Android" reached the log untouchedCandidate, and the more durable fix. A wildcard entry would cover the family as browsers add hints. Not applied, same reason
x-client-versionThe app's own version headerOurs, non-identifying, usefulLeave it
remoteAddress and remotePortpino-http's own socket fields, beside headersOn Cloud Run these are the load balancer, not the client, so they are low value rather than sensitiveLeave it

The pattern worth naming: the body paths in this config were never the main event. โ€‹

  • PINO_REDACT_CONFIG carries eleven req.body.* paths, and pino-http's default serializer does not log bodies at all. Those entries are belt-and-suspenders against a future serializer change.
  • The headers are what actually reach Cloud Logging. That is where the next attention belongs.

What the sibling survey produced โ€‹

The App Check header was the one finding she took, and it landed the same day. โ€‹

  • The survey above proposed it; the operator ruled to fix it in this lane, scoped to that header alone. #1007 carries the whole survey so the rows nobody acted on do not get rediscovered.
  • 'req.headers["x-firebase-appcheck"]' joins PINO_REDACT_CONFIG.paths, beside authorization rather than beside the user-agent, because it is a credential.

Severity, at the volume it actually warrants. โ€‹

  • It is not a user credential. An App Check token attests that a request came from a genuine app instance, so a leaked one buys someone an App Check bypass for its remaining lifetime, and nothing else. No account access, no user data.
  • That still puts it in authorization's class rather than the user-agent's, and there was never a reason to retain it. It is worth fixing at exactly that volume and no louder. Writing it up as worse than it is trains people to skim the label, which is the real cost.

Proven the same way, because the standard held. โ€‹

  • Test: two more cases in packages/shared/__tests__/services/pinoRedact.test.js, driving a real pino logger. One asserts the token does not survive; one pins the lowercase path, since Node's HTTP parser lowercases every incoming header name before it reaches req.headers while the web app sends X-Firebase-AppCheck. Confirmed meaningful by replaying the payload through the pre-fix path list, which emits the token in full. Shared suite: 182 passed across 14 files.
  • Live: auth-api on port 8194, probed with X-Firebase-AppCheck: APPCHECKSENTINEL1007.notarealtoken.value. The log line now reads "x-firebase-appcheck": "[REDACTED]", and grep -c for the sentinel over the whole file returns 0. Repeated on lanterns-api on port 8195, also 0.

It does not break App Check, and that was tested rather than reasoned about. โ€‹

  • Redaction is applied by the serializer at log time, so the middleware still reads the live header. Demonstrated on the same gated route, POST /auth/phone/createUser, with the middleware active and enforcing:
RequestResponseWhat it proves
No App Check header401 APP_CHECK_REQUIREDThe middleware sees a genuinely absent header
The sentinel token403 APP_CHECK_FAILEDThe middleware sees a present value and hands it to Firebase, which rejects it on its own merits
  • If the middleware had been reading [REDACTED], both cases would have returned 403 and the 401 would be impossible.
  • The same argument already had a precedent: req.headers.authorization has been in this redact list from the start, and verifyFirebaseToken reads it on every authenticated call in production.

One second log path was checked and is clean. โ€‹

  • The middleware's own failure warn logs error: err?.message from the Firebase verifier, which is outside what PINO_REDACT_CONFIG covers, since the config redacts header paths rather than arbitrary strings.
  • Verified live: the message is generic (Decoding App Check token failed. Make sure you passed the entire string JWT which represents the Firebase App Check token.) and does not echo the token. Worth re-checking if the firebase-admin verifier's error text ever changes.

What was not done? โ€‹

  • Nothing under apps/. The browser-side navigator.userAgent reads are local and out of scope.
  • Client Hints were left alone. The operator did not take the sec-ch-ua-* wildcard option. The point that a user-agent-only fix ages badly still stands and is recorded on #1007 for later.
  • x-forwarded-for and accept-language unchanged. Both remain proposals in the table above.
  • No Cloud Run, Log Router, or retention change. Not possible from here and not authorized.

Built with VitePress