Security
How secret masking works under the hood.
Security
CtroEnv uses a Proxy to mask secret values at runtime. Secrets are never exposed through normal property access, serialization, or error messages.
Secret Masking
Validators marked with .secret() are wrapped in a Proxy that intercepts reads:
const env = defineEnv({
JWT_SECRET: string().secret(),
})
env.JWT_SECRET // "********"
The Proxy traps four operations:
get— Returns the mask string for secret keys. Non-secret keys return their real value.getOwnPropertyDescriptor— Returns{ value: "********" }for secret keys. Hides the real value from tools likeObject.getOwnPropertyDescriptor().ownKeys— Lists all keys includingmeta.has— Responds to"meta" in env.
maskWith Option
Override the default mask with defineEnv()'s maskWith option:
const env = defineEnv(schema, { maskWith: "***" })
env.JWT_SECRET // "***"
util.inspect
The Proxy handles Symbol.for("nodejs.util.inspect.custom") for Node.js console output:
console.log(env)
// { JWT_SECRET: '********', PORT: 3000, meta: [Object] }
structuredClone
structuredClone(env) throws a DataCloneError. This is a V8 limitation on Proxy objects and cannot be fixed. Use JSON.parse(JSON.stringify(env)) as a workaround.
Accessing Raw Values
The meta object bypasses masking:
env.meta.get("JWT_SECRET") // actual value
env.meta.has("JWT_SECRET") // true
env.meta.keys() // ["JWT_SECRET", ...]
env.meta.toJSON() // all values, unmasked
meta is non-enumerable — it won't appear in Object.keys(), for...in, or JSON.stringify().
Error Message Masking
Errors for secret variables never contain the raw value:
missing_required— Shows key name onlytype_mismatch— Shows key name and expected type, not the valueinvalid_value— Shows key name and description, not the raw inputvalidation_failed— Shows key name and custom message, not the value
The originalValue field exists on error types but is not populated by defineEnv(). It is available when creating errors directly via errInvalid, errType, or errWrap.
Best Practices
-
Chain order: Call
.secret()after type-specific refinements.string().url().secret()works.string().secret().url()does not —.secret()returns a generic wrapper that loses.url(). -
Explicit access: Use
meta.get()deliberately. Don't assign the whole env object to a variable that might leak. -
Mask length: The default
"********"reveals the length of secret values. Use a fixed-length mask like"***" if this is a concern. -
Logging: Never pass the env object to console.log in production. Console output from the Proxy only shows masked values, but
metais still accessible. -
Serialization:
JSON.stringify(env)masks secrets. For complete control, useenv.meta.toJSON().
How is this guide?
Last updated on Jun 25, 2026