Skip to content

Redacting secrets

redact masks what it matches with [redacted], and redactDefaults adds a curated set of patterns. Every rule runs over each string a $debug call hands the plugin (each argument, every key and string inside a logged object, an Error's message and stack, the namespace, a per-call prefix, the source path) and over the line as it will be printed.

Redaction is a safety net, not a vault. It catches a secret you named when one ends up in a $debug call by mistake, so a forgotten log line does not put a password in your terminal, your log file, a CI log or a screenshot. It does not make it safe to log a secret on purpose: the only safe secret is one you never log.

String entries and RegExp entries

Where an entry applies depends on its kind:

EntryMasksSent to the page
RegExp, and every redactDefaults patternEvery surface: browser console, dev terminal, SSR output, log file, Vue DevTools, test: 'log'Yes, as written
StringThe server side only: dev terminal, log file, SSR output, test: 'log' outside Vitest browser modeNever
  • A string entry is where a secret's actual value goes, read from the environment. It stays in the memory of the process that loads your config: no module the plugin generates holds it, so it never reaches the browser. The dev server masks it in every line the browser forwards, but it does not mask the browser console or Vue DevTools.
  • A RegExp entry is written into the runtime the page loads, where any script on the dev origin can read it. It must describe a secret's shape, never contain the secret. To mask a value in the browser console too, add a RegExp for its shape that matches the whole value: in the browser, it is the only kind of entry that applies.
ts
// vite.config.ts
import { defineConfig, loadEnv } from "vite";
import mtDebug from "@mars-tech/vite-debug-plugin";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), ""); // all of .env, VITE_ prefix or not
  return {
    plugins: [
      mtDebug({
        redact: [
          env.DB_PASSWORD, // string: server side only, every occurrence
          /sk_live_[A-Za-z0-9]{8,}/, // RegExp: a secret's shape, masked everywhere
          /(?<=x-api-key:\s*)\S+/, // a lookbehind keeps the header name, masks the key
          /(?<="password"\s*:\s*)"(?:[^"\\]|\\.)*"/, // only the value is masked
        ],
        redactDefaults: true, // also mask JWTs, Bearer tokens, emails, sk_/pk_/rk_ keys, secret JSON values
      }),
    ],
  };
});
ts
$debug("info", { user: "ada@example.com", token: "Bearer abc.def" });
// MARS-TECH Info - {"user":"[redacted]","token":[redacted]}   (with redactDefaults)

The token value loses its quotes: the default pattern for secret keys masks the whole quoted value and keeps the key through a lookbehind.

A string entry matches literally, every occurrence, as written, and also as JSON escapes it, so a password holding a ", a \ or a newline is still found inside a logged object. A number is matched as the literal it prints as. An empty or unset value (env.X with no such variable) is ignored; any other value, such as an object, is ignored with a warning.

A string entry is only as private as your dev setup. Run Vite 6.4.2, 7.3.2, 8.0.5 or later: older releases hand a page script any file, your .env included. Never put production keys in a dev setup. If your code logs secrets, even redacted ones, do not turn on the NDJSON log file: it is a file on disk that any tool reading files can read. A log you keep anyway is safer outside the project (outside server.fs.allow, which in a monorepo is the workspace root), where Vite refuses every file: see Keeping the log from the page.

Writing a RegExp entry

  • Describe the shape, never the value. Write /sk_live_[A-Za-z0-9]+/, not the key itself: a RegExp is sent to the page as written.
  • Match the whole secret. A RegExp masks its whole match, and nothing more. It is applied with the global flag forced on, so every occurrence is masked, and the sticky flag y dropped.
  • Keep the text in front of a secret with a lookbehind. A capture group keeps nothing of the match: the $1 convention of 0.6 is gone. /(?<=x-api-key:\s*)\S+/ masks the key and keeps the header name, where /x-api-key:\s*\S+/ masks both. A 0.6 pattern with a leading group, such as /("password"\s*:\s*)"[^"]*"/, now masks its key too; rewrite it as /(?<="password"\s*:\s*)"[^"]*"/ (see Every match is masked whole).
  • A pattern sees each string and the line. A RegExp runs over each string your code passed, where ^ and $ anchor to that string, and over the printed line, where an object is JSON and several values are joined: /x-api-key:\s*\S+/ over $debug("info", "x-api-key:", obj) reaches into the object. In the line, a " or a newline inside an object is escaped (\", \n).
  • Keep it linear-time. The dev server runs your RegExp entries over every line a page forwards, text any script on the dev origin can send. Two common shapes backtrack badly: nested quantifiers such as /(\w+\s?)+$/, and a repeated class in front of a literal such as /[a-z]+@corp\.example/. Either can stall the dev server for seconds on a single forged line. The defaults and string entries are linear.

Default patterns

redactDefaults is opt-in (default false). It errs toward high-confidence patterns and skips bare digit runs, so order ids and timestamps stay readable. It masks:

  • JWTs;
  • Bearer tokens;
  • Basic credentials after an Authorization: header (a bare "basic" is an ordinary word);
  • email addresses;
  • Stripe-style keys such as sk_live_…, pk_test_… or rk_live_…;
  • the value of a secret key (password, passphrase, token, api_key, x-api-key, private_key, credentials, cookie and similar), when it is a string, a number or a flat array of them. The key stays readable. This works for an object property, for a [key, value] pair, which is how a Map and Object.entries() render (Map(1) [["password",[redacted]]]), and inside the JSON a logged string holds: a fetch init's body: JSON.stringify({ password }) prints {"body":"{\"password\":[redacted]}"}.

The defaults do not cover an object value as a whole (the secret keys inside it are masked one by one), an array that holds an array or an object, a URL-encoded password=…, the { password: '…' } syntax of util.inspect, or an opaque value such as ?token=deadbeefcafe1234. Add a pattern for those.

The default patterns use lookbehinds: with redactDefaults: true, a Safari that runs your dev page must be version 16.4 or newer. The same goes for a RegExp entry of yours written with a lookbehind.

What redaction does and does not cover

The plugin guarantees one thing, on every surface an entry reaches: it never introduces a leak. Every rule runs over each string a $debug call hands it, exactly as your code passed it (the format string, a { tag } and the labels of count(), time() and group() included), and over the line as printed. Every match prints as [redacted], whole, and nothing the plugin does afterwards brings a part of it back, whether printf substitution, JSON rendering, the 500-character cut of a long object or forwarding a browser line to the dev server. Filters still see the real name: ?mt-debug=-user:* mutes ns("user:" + email) even when the email is masked.

It does not cover:

  • A secret your code changed before logging it. It is a different string: secret.slice(0, 8) + "…", encodeURIComponent(secret), new URL(secret).href, base64, util.inspect(…), or JSON your code escaped twice. Add an entry for the form you log, or do not log it.
  • { inspect: true }, which hands the live object to the browser console because you asked for it (see Live values in the browser).
  • Output $debug does not produce: console.log, Vite's own messages, uncaught errors, and tools that copy the browser console elsewhere.

A few consequences worth knowing:

  • A library's debug/ build ships no redactor of its own: its calls reach your app's $debug and are masked by your redact and redactDefaults.
  • Several dev servers in one Node process (Vitest projects, Nuxt's client and SSR builds) mask each other's server-side output with all their rules, and a rule you remove keeps masking until the process restarts. Two vite runs share nothing.
  • A printf number can be masked on the terminal. With string entries configured, a %d, %i or %f number in a browser line that reads differently from its argument prints as [redacted] on the terminal and in the log file: $debug("info", "%d items", "007") shows 7 items in the browser console and [redacted] items on the terminal. Pass an integer, or use %s.

Live values in the browser

In the browser, $debug normally also hands the console the live value (see Live object inspection). Whenever a browser-side redactor is configured, a RegExp entry or redactDefaults, it stops: the browser console and the Vue DevTools timeline get the redacted text instead of a live, expandable object, because a live object would sidestep string-level masking. That includes DOM nodes, whose href, data-* attributes and input value would render unmasked. table() and dir() print the redacted summary instead of the native table or tree.

{ inspect: true } opts a single call back in, in the console only: Vue DevTools keeps the redacted text. table() and dir() have no such switch: call console.table directly for data you know is safe. With string entries alone, live values are kept and table() and dir() render natively, since those entries never mask the browser anyway.

Error stacks

With showStack, an Error's stack is masked like its message, and what the rules matched in the message is masked in the stack too.

  • Stacks are scanned, not made safe. Frame URLs often carry credentials. The curated defaults catch JWTs, Bearer tokens, emails and sk_/pk_/rk_ keys inside a frame URL, but not opaque values such as ?token=deadbeefcafe1234 or ?X-Amz-Signature=…, which look like ordinary hex. Add your own pattern for those.
  • A redacted frame stops being clickable. Rewriting text inside a frame URL breaks devtools linkification and source-map resolution for that frame, and a broad pattern (a bare hex regex, say) can mask legitimate path segments and Vite's ?v= hashes. Prefer narrow patterns. The same goes for a source location whose path a rule matched.

Vite 8 console forwarding

Vite 8 can copy what the page prints with console.error, console.warn or any method listed in server.forwardConsole to the dev terminal, and turns this on for console.error and console.warn by itself when it detects a coding agent. That copy would print a $debug line as the browser console has it: your string entries in clear, and a { inspect: true } value unmasked. So whenever a redactor is configured and you have not set server.forwardConsole, the plugin takes the console methods $debug prints with out of Vite's forwarding: unhandled errors are still forwarded, but your own console.error and console.warn calls no longer are.

If you set server.forwardConsole yourself, it is left as written and your own calls are forwarded as you asked; $debug then prints with the console methods the page had before Vite wrapped them, so none of its lines takes that route. Vite 6 and 7 have no such option.

Where string entries cannot reach

Each printer in Node reads your string entries from the process that loads your config, at each print. They reach the Vite process (the dev server, ssrLoadModule, the module runner, vite-node), a worker thread started after the plugin (Nuxt's Nitro worker, Vitest's threads and vmThreads pools), and, from Vitest 4, the forks and vmForks workers.

Where they cannot be read, $debug prints nothing, rather than print a line with your string entries unmasked, and warns once. SSR in a process of its own, for instance, prints:

text
[MARS-TECH] $debug prints nothing in this process: the string entries of your redact option do not reach it, and it does not print what it cannot mask. They reach SSR in the Vite process and in a worker thread started after the plugin, and Vitest workers (the forks and vmForks pools from Vitest 4). Elsewhere - SSR in a process of its own, workerd - describe each such value with a RegExp for its shape instead.
With string entries, $debug prints nothing inTo see output there
SSR in a process of its own, in workerd (@cloudflare/vite-plugin), or in a runtime without process.getBuiltinModuleA RegExp for each value's shape
Vitest 3's forks and vmForks pools (forks is its default)Vitest 4, or pool: "threads"
A Vitest project that runs in a browser, and Vitest's browser pageA RegExp for each value's shape
A worker thread started before the pluginRestart the worker
A logger you install yourself (installMtDebugConsole) in a forks worker nothing handed the rules totest: 'log'

Without string entries, every printer prints, masked by your RegExp entries and the defaults. For the setups involved, see String redact entries on the server and String redact entries in tests.