DEV Community

Vin Lookup
Vin Lookup

Posted on

Structured Logging for VIN Decode Pipelines Without Cleartext VINs

Logs are how you debug a flaky NHTSA call at 2 a.m. They are also how a VIN ends up in a support ticket, a third-party APM export, or a Slack alert that lives forever. A free VIN decode tool that prints full 17-character identifiers into every info line is quietly building a secondary PII store.

This post is about safe structured logging for decode pipelines: keep enough signal to triage errors, never store full VINs in cleartext by default, and still correlate a user session to a failed upstream response.

Why VINs are log-sensitive

A VIN is not a password, but it is a durable vehicle identifier. Combined with IP, user id, or listing id, it becomes a tracking key across marketplaces, insurance tools, and scrapers. Compliance teams often treat it like personal data even when your product is "just decoding."

Common leak paths:

  • Request middleware that logs req.body or query strings
  • Error serializers that dump the whole decode context
  • Retry libraries that echo the failed URL (including path segments)
  • Client analytics that forward the form value on every submit

If your default is "log the VIN," you will eventually ship it somewhere you cannot delete.

Log a fingerprint, not the plate

Keep a short, stable fingerprint for correlation. Hash the normalized VIN with a server-side pepper so the same vehicle collides in your logs without revealing the string to anyone who reads the file.

import { createHmac } from "node:crypto";

const PEPPER = process.env.VIN_LOG_PEPPER ?? "";

export function vinFingerprint(vinNormalized: string): string {
  if (!PEPPER) {
    throw new Error("VIN_LOG_PEPPER is required for safe logging");
  }
  // 12 hex chars (~48 bits) is enough to group retries without being reversible
  return createHmac("sha256", PEPPER)
    .update(vinNormalized)
    .digest("hex")
    .slice(0, 12);
}

export function vinLogHints(vinNormalized: string): {
  vinFp: string;
  wmi: string;
  yearCode: string;
} {
  return {
    vinFp: vinFingerprint(vinNormalized),
    wmi: vinNormalized.slice(0, 3),
    yearCode: vinNormalized.charAt(9),
  };
}
Enter fullscreen mode Exit fullscreen mode

WMI and year code are already quasi-public taxonomy. They help you answer "is Toyota Japan traffic failing?" without printing the serial. Do not log the last six (VIS) in cleartext; that is the unique part.

Structured fields that earn their keep

Prefer a flat JSON line with explicit fields over free-form messages:

type DecodeLog = {
  event: "decode.start" | "decode.ok" | "decode.fail";
  vinFp: string;
  wmi: string;
  yearCode: string;
  source: "nhtsa" | "cache" | "negative";
  latencyMs: number;
  errorCode?: string;
  httpStatus?: number;
  requestId: string;
};

export function logDecode(entry: DecodeLog): void {
  // Never spread arbitrary objects -- they may contain vin
  console.log(JSON.stringify(entry));
}
Enter fullscreen mode Exit fullscreen mode

Rules that keep this honest:

  • Ban a vin key in the schema. TypeScript makes the mistake a compile error.
  • Put requestId (or trace id) on every line so you can join without the VIN.
  • Record errorCode from your domain (CHECK_DIGIT, UPSTREAM_429) not the raw exception message if that message embeds the VIN.
  • Cap string lengths on any message field you still keep for humans.

Scrub before it leaves the process

Defense in depth: even if a developer adds console.log({ vin }) later, a redactor on the logger transport catches common shapes.

const VIN_RE = /\b([A-HJ-NPR-Z0-9]{17})\b/gi;

export function scrubVinCleartext(text: string): string {
  return text.replace(VIN_RE, (match) => {
    const n = match.toUpperCase();
    return `${n.slice(0, 3)}***${n.charAt(9)}***`; // WMI + year hint only
  });
}
Enter fullscreen mode Exit fullscreen mode

Apply scrubVinCleartext to outbound log payloads, error reports to Sentry-style tools, and any webhook that mirrors failures to chat. Masking is not a substitute for never putting the VIN in the object; it is the seatbelt when someone forgets.

What to log on NHTSA failures

Upstream failures need operational detail without the identifier:

  • HTTP status and a short upstream body hash (not the body)
  • Whether the circuit breaker is open
  • Cache hit/miss and negative-cache age
  • Batch index when using DecodeVinValuesBatch (index + fingerprint, not the VIN list)

Example fail line:

{"event":"decode.fail","vinFp":"a3f9c1e2b0d4","wmi":"1HG","yearCode":"3","source":"nhtsa","latencyMs":842,"errorCode":"UPSTREAM_503","httpStatus":503,"requestId":"req_01HZX..."}
Enter fullscreen mode Exit fullscreen mode

That is enough to page on 503 spikes for WMI 1HG without handing support a spreadsheet of customer vehicles.

Access and retention

Treat decode logs like customer data:

  • Short retention on hot storage (days, not years) unless compliance requires more
  • Separate "debug unlock" that temporarily allows a full VIN in a restricted audit stream, gated by role and ticket id
  • Never echo fingerprints' peppers into the same log stream

If law enforcement or a user asks for a specific decode, look it up in your product database under access control -- not in the shared logging backend.

Product rules

  • Normalize, then fingerprint; never hash raw paste with spaces.
  • Default schema has vinFp, never vin.
  • Scrub regex on every export path (APM, chat, email digests).
  • Metrics use fingerprints or WMI aggregates; dashboards do not need cleartext.
  • Document the pepper rotation story before the first deploy.

Takeaway

Useful decode logs answer "which class of vehicle failed, how often, and under which upstream status?" They do not need the full VIN sitting next to timestamps forever. Fingerprint for correlation, keep WMI/year hints for taxonomy, scrub cleartext on the way out, and keep any full-VIN audit trail behind a deliberate unlock. Your on-call stays effective; your log sink stops becoming a VIN warehouse.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)