Secrets management
Varlock uses the term sensitive to describe any value that should not be exposed to the outside world. This includes secret API keys, passwords, and other generally sensitive information. Instead of relying on prefixes (e.g., NEXT_PUBLIC_) to know which items may be “public”, varlock relies on @decorators to mark sensitive items explicitly.
Because we understand which values are sensitive, we can apply extra security guardrails to keep them safe at every step of the SDLC.
Identifying @sensitive items
Section titled “Identifying @sensitive items”Whether each item is sensitive or not is controlled by the @defaultSensitive root decorator and the @sensitive item decorator. For example:
# @defaultSensitive=false# ---# not sensitive by default (because of the root decorator)NON_SECRET_FOO=# @sensitive # explicitly marking this item as sensitiveSECRET_FOO=These decorators are typically used only in your .env.schema file, but may be used in any file, which can be useful to override sensitivity in a specific environment.
Values varlock can’t protect
Section titled “Values varlock can’t protect”Marking an item @sensitive is a claim that varlock will keep the value out of your output. Redaction makes good on that by replacing the value wherever it appears, which only works for a string long enough not to occur in ordinary text. Some values can’t be protected that way at all, so varlock reports them rather than letting the decorator promise something it isn’t delivering.
| The value | What happens |
|---|---|
| under 12 characters | warning |
| under 3 characters | error if you wrote @sensitive, otherwise a warning |
| a boolean | error if you wrote @sensitive, otherwise a warning |
| a number | error if you wrote @sensitive, otherwise a warning |
| an array or object with non-string elements | error if you wrote @sensitive, otherwise a warning |
the @currentEnv item | error if you wrote @sensitive, otherwise a warning |
| a secret embedded in a non-sensitive item’s value | warning, on the non-sensitive item |
Short values. Redaction has no token boundary, so a sensitive acmeco rewrites every acmeco in your logs and proxied responses, including text that was never a secret. A value that common isn’t meaningfully protected by redacting it either. Set @sensitive=false when it isn’t really a secret, like an org slug or an account name. Some secrets are short by nature, like a one-time code or a PIN, and can’t be lengthened; @sensitive={allowShortValue=true} records that you read the collision risk and accepted it. Under 3 characters the collision is a certainty rather than a risk, so the acknowledgement doesn’t apply.
Booleans and numbers. A boolean holds one bit, so there’s no secret in it to protect, while redacting it would rewrite every true/false you print. A number is never redacted at all, because redaction only replaces strings. For a numeric secret, make it a string with @type=string or by quoting the value (PIN="007123"), which also preserves the leading zeros and the precision past 2^53 that a number drops.
Composite values are measured per element, since each element is registered for redaction on its own. That means an array(number) follows the same rule as a bare number, and a long array with a one-character element is still a one-character match.
The @currentEnv item drives conditional imports and forEnv(), and gets echoed by most tools in your stack. It’s a mode name, not a secret, and redacting it rewrites that name everywhere it appears.
A secret inside a non-sensitive value is reported on the non-sensitive item, since marking that item sensitive is usually the fix. Two things can be wrong: a long secret embedded in a public value is carried into logs, generated types, and client bundles where none of its protections apply; a short one will be redacted out of the containing value everywhere that value legitimately appears.
Which ones fail the load
Section titled “Which ones fail the load”Nothing an item inherited from @defaultSensitive can fail your load. Every rule above is an error only when you wrote @sensitive on the item yourself, and a warning when @defaultSensitive=true swept it in: that default is what a hand-written schema inherits, so it picks up every port, feature flag, region code and env name in the file, and failing those would be a bigger break than the problem warrants. Either way the fix is the same, @sensitive=false (or @public). Builtin VARLOCK_* variables are exempt from all of it.
None of this changes an item’s sensitivity for you. Making a value public would also make it @static and eligible to be inlined into a build, so varlock reports the problem and leaves the decision to you.
Local encryption via varlock()
Section titled “Local encryption via varlock()”For local git-ignored secrets (typically overrides in .env.local), use the varlock() function to store encrypted values on disk.
Note that the encryption key used is tied to your device, so these encrypted values are not meant to be shared or committed to git.
PLAINTEXT=shh-im-secret # 🚨 dangerSECURED=varlock(local:abc123...) # ✅ secured at rest
NEW_ITEM=varlock(prompt) # will prompt for new valueSee the local encryption guide for full setup, platform-specific details, and related CLI commands like varlock encrypt, varlock reveal, and varlock lock.
On macOS, you can also use the built-in keychain() plugin, which provides a similar experience but stores encrypted values in the system keychain.
Loading secrets from external sources
Section titled “Loading secrets from external sources”Using plugins (recommended)
Section titled “Using plugins (recommended)”varlock provides official plugins for popular secret management platforms, giving you a type-safe way to fetch secrets directly in your .env files.
Available plugins include:
- 1Password
- AWS Secrets Manager & Parameter Store
- Azure Key Vault & App Configuration
- Bitwarden
- Google Secret Manager
- HashiCorp Vault
- Infisical
See the plugins overview for the complete list.
Plugins are able to register new decorators and resolver functions that declaratively fetch secrets:
# Install and initialize the 1Password plugin# @plugin(@varlock/1password-plugin)# @initOp(token=$OP_TOKEN, allowAppAuth=forEnv(dev))# ---
# Load secrets using the op() resolver function# @sensitive @requiredMY_SECRET=op(op://my-vault/item-name/field-name)Benefits of using plugins:
- Declarative secret references safe to check into version control
- Built-in validation and type safety applied to fetched values
- Built-in authentication handling
- Better error messages and debugging
- Platform-specific features (e.g., biometric unlock for 1Password)
See each plugin’s documentation for detailed setup instructions.
Using exec() as a fallback
Section titled “Using exec() as a fallback”For cases where a plugin doesn’t exist or you need custom logic, varlock supports fetching secrets via CLI commands using exec() function syntax.
# A secret fetched via a custom CLI / script# @sensitive @requiredMY_SECRET=exec(`./scripts/fetch-secret.sh prod/api-credential`)This approach works with any CLI tool, ensuring no secrets are left in plaintext on your system, even if they are gitignored. If a plugin already exists for your provider (1Password, AWS, Vault, …), prefer it over exec(). Plugin resolvers like op() handle auth and caching for you.
Bulk injection with @setValuesBulk()
Section titled “Bulk injection with @setValuesBulk()”For some secret management platforms, you may already be setting key names that match your environment variable names - in which case, wiring up each value can feel like a lot of boilerplate.
In cases like this, you can set many values at once using the @setValuesBulk() root decorator.
For example, using 1Password, you could store a .env style blob within a text field, or you could fetch values from their new environments tool.
# fetch a dotenv style blob within a text field# @setValuesBulk(op("op://vault/item/field"))## load values in a 1Password environment# @setValuesBulk(opLoadEnvironment(your-environment-id), createMissing=true)## load all secrets from an Infisical project environment# @setValuesBulk(infisicalBulk())## load Infisical secrets filtered by path or tag# @setValuesBulk(infisicalBulk(path="/database", tag="backend"))## load all key/value pairs from a HashiCorp Vault path# @setValuesBulk(vaultSecret("secret/myapp/config", raw=true))## inject only specific keys (pick allowlist), or everything except some (omit denylist)# both accept simple globs like API_*# @setValuesBulk(opLoadEnvironment(your-environment-id), pick=[API_KEY, DB_*])# @setValuesBulk(infisicalBulk(), omit=[LEGACY_TOKEN])The bulk values are injected at the precedence level of the file containing the decorator, so .env.local and process.env will still override them as expected. See the reference docs for full details.
Security enhancements
Section titled “Security enhancements”Unlike other tools where you have to rely on pattern matching to detect sensitive-looking data, varlock knows exactly which values are sensitive, and can take extra precautions to protect them.
CLI log redaction
Section titled “CLI log redaction”The varlock CLI itself redacts sensitive values in its own output. Commands like varlock explain will display resolved values with partial masking (e.g., my▒▒▒▒▒) so you can verify configuration without exposing secrets in your terminal scrollback.
When you need to see the actual plaintext value, use varlock reveal which provides a secure viewing experience:
varlock reveal # interactive picker to select a secretvarlock reveal MY_SECRET # reveal a specific variablevarlock reveal MY_SECRET --copy # copy to clipboard (auto-clears after 10s)The reveal command displays values in an alternate screen buffer. When you press any key to dismiss, the value disappears from your terminal entirely and won’t be visible in scrollback history. With --copy, the value is copied to your clipboard and automatically cleared after 10 seconds.
Redaction in varlock run
Section titled “Redaction in varlock run”When the output of varlock run is piped or redirected (CI logs, files, | tee, etc.), the child process’s stdout/stderr is piped through the same redaction engine, so any sensitive values that end up in your application’s output will be masked automatically before they persist anywhere. A value split across output chunks is still caught: when a chunk ends with what looks like the start of a sensitive value, that part is held back until the next chunk arrives, for up to 100ms. See values split across writes for the rare case where the child pauses longer than that.
When output is attached to an interactive terminal, it passes straight through instead. Piping would break interactive tools (e.g., psql, claude) that rely on TTY detection, and a human at the terminal already has access to the secrets. --no-redact-stdout disables redaction entirely. --redact-stdout forces it on piped output, which is how you override @redact=false; it errors when output is attached to an interactive terminal, where redaction would break TTY behavior.
varlock run --no-redact-stdout -- node app.js | tee log.txt # force-disable redaction for piped outputvarlock run --redact-stdout -- node app.js > log.txt # force redaction despite @redact=falseThe same override can be set with the _VARLOCK_REDACT_STDOUT environment variable (true/1 or false/0) when you can’t easily change the command, e.g. in a wrapper script or CI config. The CLI flag takes precedence over the env var.
Runtime log redaction
Section titled “Runtime log redaction”Only available in JavaScript/Node.js projects using varlock’s runtime integrations.
When using varlock/auto-load or a framework integration, varlock automatically patches global console methods (log, warn, error, debug, info, trace) to redact any sensitive values before they reach stdout/stderr. Sensitive values are replaced with a partially masked string using the ▒ character. For example, my-secret-value becomes my▒▒▒▒▒.
console.log(process.env.SECRET_KEY);// outputs "my▒▒▒▒▒" instead of "my-secret-value"This works by intercepting Node’s console internals, with an additional layer that wraps the console methods themselves to handle environments where console.log has been patched by the platform (e.g., AWS Lambda) or where those internals do not exist at all (edge runtimes like Cloudflare Workers and Vercel Edge).
Redaction covers whatever you pass to the console methods: strings, arrays, plain objects, and Error objects (including messages, stack traces, and anything nested inside). Varlock logs a redacted copy rather than modifying what you passed in.
Redacting stdout and stderr
Section titled “Redacting stdout and stderr”The console patch only sees what goes through console. Code that writes to the streams directly, like process.stdout.write, loggers such as pino, CLI frameworks, or child.stdout.pipe(process.stdout), bypasses it. To redact those too, opt in with @redact={stdout=true}:
# @redact={stdout=true}# ---SECRET_KEY=my-secret-value # @sensitiveThis patches process.stdout.write and process.stderr.write (and Bun.write to Bun.stdout/Bun.stderr) using the same rule as varlock run: only streams that are not attached to an interactive terminal are redacted. Each write is redacted and passed through right away, so stream behavior (callbacks, errors, ordering) is unchanged. Nothing is held back, so a value split across separate writes is only partly redacted (see values split across writes). Binary data and non-UTF-8 string encodings (e.g. write(str, 'base64')) pass through untouched. When the process runs under varlock run, which already redacts its output, the in-process patch skips those streams. You can also turn it on without changing the schema by setting _VARLOCK_REDACT_STDOUT=1.
In-process stdout redaction is opt-in for now and will be on by default in the next major version. Keep in mind that it changes your program’s output: if stdout carries data rather than logs (a script whose output is captured with $(...), a generator writing to > file, a stdio protocol), any sensitive value in it is masked. Mark values that are meant to be printed with @sensitive={redact=false}, or set @redact={stdout=false} for a process whose stdout is a data channel.
Writes that skip the stream objects, like fs.writeSync(1, ...), pino.destination() and logger transports running in worker threads, are not covered in-process. Run under varlock run to redact those, since it redacts at the file descriptor level.
Values split across writes
Section titled “Values split across writes”Redaction matches whole values, so a value printed in two separate pieces is harder to catch. This is rare: console and loggers write whole lines. It mostly happens when output is relayed in arbitrary chunks (child.stdout.pipe(process.stdout)), or when a program buffers its piped output and pauses partway through printing a value.
When it does happen, the part of the value after the split is masked, but the first part was already printed. varlock then prints a one-time warning naming the item:
[varlock] the sensitive value of API_KEY was split across ...If you see it:
- Treat the value as partly exposed. If that output was saved anywhere (CI logs, log files), rotate the value.
- Stop printing it. The warning means the value was written to output at all, which is usually the real bug.
- Avoid the split. Under
varlock run, splits only happen when the child pauses for more than 100ms mid-value, so make it write output line by line (e.g.PYTHONUNBUFFERED=1,stdbuf -oL <command>). In-process redaction never holds output back, so a process that relays output in chunks should run undervarlock run, which does.
If you need to intentionally reveal a secret in logs (for example during debugging), you can use the revealSensitiveConfig helper:
import { revealSensitiveConfig } from 'varlock/env';console.log(revealSensitiveConfig(process.env.SECRET_KEY));// outputs the actual valueTo disable runtime log redaction, set the @redact root decorator to false. To exempt a single value, use @sensitive={redact=false}.
Leak prevention
Section titled “Leak prevention”Only available in JavaScript/Node.js projects using varlock’s runtime integrations.
Varlock scans outgoing HTTP responses at runtime to detect if any sensitive values are being accidentally sent to clients. If a leak is detected, varlock throws an error with a detailed diagnostic message including the config item key and where the leak was detected. The rejected body is never delivered and the request fails rather than hanging, so look to your server log for the diagnostic.
This works by patching:
- Node.js
ServerResponse: interceptswrite()andend()calls, scanning text and JSON response bodies, including compressed ones - Global
Responseconstructor: intercepts theResponseclass used in edge runtimes (e.g., Cloudflare Workers), scanning bodies passed to the constructor andResponse.json()
Streamed responses are scanned across chunk boundaries, so a sensitive value that gets split between two writes is still detected. When the end of a chunk looks like the start of a sensitive value, that trailing text is held back briefly (up to 15ms, or until the next chunk arrives) so the full value can be caught before any of it is sent.
Our framework integrations automatically apply the appropriate patches for your environment. For example, a Next.js integration will scan both server-rendered pages and API route responses.
To disable leak prevention, set the @preventLeaks root decorator to false.
Scope and known limitations
Section titled “Scope and known limitations”Redaction and leak prevention are guardrails against accidental exposure by code you trust: a stray console.log, a debug dump, a template that echoed a config object. They are not a sandbox. Your code has the plaintext (it is in process.env), and code that wants to get a value out can always do so by some path varlock does not watch. If the process itself is untrusted, for example an agent or a build step you did not write, use the credential proxy, which keeps the plaintext out of the process altogether.
The following behaviors are known and intended. They are documented here so you can design around them, and so they are not reported as vulnerabilities (see the project’s security policy).
What is matched. Redaction and leak scanning look for the exact string of each sensitive value (and, for arrays and objects, each element and the serialized form). A value that has been transformed before it is printed or sent is not recognized: base64, hex or URL encoding, a different case, a reversed or truncated string, a hash. Short values are a special case covered in values varlock can’t protect.
Where it looks. Console redaction sees what goes through the console methods. Stream redaction (@redact={stdout=true} or varlock run) sees what is written to process.stdout and process.stderr, or to the file descriptors behind them under varlock run. Leak prevention sees HTTP response bodies, including gzip, deflate and brotli compressed ones, but not headers, cookies, WebSocket frames, or anything the server sends through another channel. Everything else is uncovered: fs.writeSync(1, ...), files, sockets, worker-thread logger transports, native addons, child processes not started under varlock run, and output written before varlock initialized or by code that captured the original console or process.stdout.write before varlock patched them.
Interactive terminals. Stream redaction, in-process and under varlock run, deliberately skips a stream attached to a TTY: a human at the keyboard already has the secrets, and piping a TTY breaks interactive programs. Console redaction applies regardless. Set @redact={console=false} if you want to see values in your own terminal while keeping piped output protected.
The masked form keeps the first two characters (my▒▒▒▒▒) so you can tell which secret a line refers to. Those two characters are visible on purpose.
Values split across writes. Covered in values split across writes. In short: varlock run catches a split unless the child pauses for more than 100ms mid-value; in-process redaction never holds output back, so the first part of a split value is printed, the rest is masked, and a warning names the item.
Overlapping occurrences. A value is replaced wherever it occurs, left to right and without overlap. When a value overlaps itself or another value (one is a prefix of the other, or a value’s end is its own beginning), the overlapping occurrence loses its first characters to the mask and the remaining tail stays visible. Real tokens are random enough that this does not happen; it matters only for contrived values.
Encodings and binary. Stream redaction rewrites UTF-8 text. Strings written with another encoding (write(str, 'base64')) and buffers that are not valid UTF-8 pass through untouched.
Opt-outs print values. @redact=false, @redact={stdout=false}, @sensitive={redact=false}, revealSensitiveConfig(), --no-redact-stdout and _VARLOCK_REDACT_STDOUT=0 all exist to print a sensitive value on purpose. Output produced under one of them is working as configured.
Leak prevention is per value, not per secret. It raises an error when an unmodified sensitive value appears in a response body. A value that reaches a client some other way, for example through a static asset the server did not generate, a value inlined into a client bundle by your bundler, or a transformed copy, is not detected. The build-time scan covers bundles.
A redaction or leak-prevention report that does not reduce to one of these is a bug we want to hear about: an unmodified sensitive value printed whole through a covered path, with no opt-out in effect, is exactly what these features exist to stop.
Scanning files for leaked secrets
Section titled “Scanning files for leaked secrets”The varlock scan command checks your project files for any plaintext occurrences of your @sensitive values. It loads your varlock config, resolves all sensitive values, and then searches through files to detect leaks.
varlock scanThis is intended to be used as a pre-commit git hook to prevent accidentally committing secrets into version control. If no sensitive values are found in plaintext, it exits successfully. If any are detected, it reports the file, line number, and which secret was found, then exits with a non-zero status code.
It can also be used to scan build output as an extra step to prevent accidentally bundling secrets into client-facing code. Our drop-in integrations usually do this automatically, but this can be useful in some scenarios.
Scanning modes
Section titled “Scanning modes”varlock scan- default mode, scans all files except gitignored onesvarlock scan --include-ignored- scans all files including gitignored onesvarlock scan --staged- scans only the files you have staged for commitvarlock scan path1 path2/**/*.js- scans specific files
Automatic setup
Section titled “Automatic setup”The easiest way to set this up is:
varlock scan --install-hookThis will detect if you use a hook manager (like husky or lefthook) and provide appropriate instructions. If no hook manager is detected, it will create a pre-commit script in git’s hooks directory (.git/hooks by default, or core.hooksPath if set).
Manual setup
Section titled “Manual setup”If you prefer to set it up yourself, add the following to your pre-commit hook:
Plain git hook (.git/hooks/pre-commit):
#!/bin/shvarlock scanMake sure the hook file is executable:
chmod +x .git/hooks/pre-commitWith husky (.husky/pre-commit):
varlock scanWith lefthook (lefthook.yml):
pre-commit: commands: varlock-scan: run: varlock scan