CtroEnv
ctroenvType-Safe Environment Variables
Getting StartedQuick StartCore Concepts
defineEnv()string()number()boolean()semver()pick()ip(), ipv4(), ipv6()uuid(), guid()Chainable MethodsRefinementsError HandlingSchema CompositionSecurityCustom Validators
CLI Overviewctroenv validatectroenv generatectroenv checkctroenv docsctroenv initCLI Configuration
Node AdapterVite AdapterNext.js Adapter
Migration from t3-envMigration from envalidMigration from dotenv

Security

How secret masking works under the hood.

  1. Docs
  2. Core API

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 like Object.getOwnPropertyDescriptor().
  • ownKeys — Lists all keys including meta.
  • 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 only
  • type_mismatch — Shows key name and expected type, not the value
  • invalid_value — Shows key name and description, not the raw input
  • validation_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

  1. 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().

  2. Explicit access: Use meta.get() deliberately. Don't assign the whole env object to a variable that might leak.

  3. Mask length: The default "********" reveals the length of secret values. Use a fixed-length mask like "***" if this is a concern.

  4. Logging: Never pass the env object to console.log in production. Console output from the Proxy only shows masked values, but meta is still accessible.

  5. Serialization: JSON.stringify(env) masks secrets. For complete control, use env.meta.toJSON().

How is this guide?

Edit on GitHub

Last updated on Jun 25, 2026

PreviousSchema CompositionNextCustom Validators

On this page

Secret MaskingmaskWith Optionutil.inspectstructuredCloneAccessing Raw ValuesError Message MaskingBest Practices