npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

node-env-resolver

v6.7.0

Published

Type-safe environment variable resolver with async resolvers - lightweight, tree-shakeable, no dependencies

Readme

node-env-resolver

Type-safe environment variable resolution with zero dependencies and ultra-small bundle size.

npm version

Bundle Size: ~6.3KB gzipped (core), code-splits for advanced features

Install

npm install node-env-resolver

TypeScript Configuration

This package uses modern package.json exports for subpath imports (like node-env-resolver/resolvers). Your tsconfig.json must use a modern module resolution strategy.

What to change:

If your tsconfig.json has:

{
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "node"
  }
}

Change it to:

{
  "compilerOptions": {
    "module": "NodeNext", // Changed from "CommonJS"
    "moduleResolution": "NodeNext" // Changed from "node"
  }
}

Alternative options:

  • For bundler projects (Vite, Webpack, etc.): Use "moduleResolution": "bundler" (you can keep "module": "CommonJS" or use "ESNext")
  • For Node.js projects: Use "moduleResolution": "node16" with "module": "node16"

Required values: "node16", "nodenext", or "bundler"
Not supported: "node" (legacy resolution doesn't understand exports)

If you see module resolution errors, see the Troubleshooting section below.

Quick start (uses process.env)

import { resolve } from 'node-env-resolver';
import { string } from 'node-env-resolver/validators';
import { protect } from 'node-env-resolver/runtime';

const config = resolve({
  PORT: 3000,
  NODE_ENV: ['development', 'production', 'test'] as const,
  DEBUG: false,
  API_KEY: string({ optional: true }),
});

// Redact secrets from all console output in one call
const unprotect = protect(config);

// Resulting type (fully inferred):
// type config = {
//   PORT: number;
//   NODE_ENV: 'development' | 'production' | 'test';
//   DEBUG: boolean;
//   API_KEY: string | undefined;
// }

// TypeScript knows the exact types:
config.PORT; // number
config.NODE_ENV; // 'development' | 'production' | 'test'
config.DEBUG; // boolean
config.API_KEY; // string | undefined

Quick start (with custom resolvers)

import { resolve, resolveAsync } from 'node-env-resolver';
import { processEnv, dotenv } from 'node-env-resolver/resolvers';
import { string, url, postgres, redis } from 'node-env-resolver/validators';
import { awsSecrets, gcpSecrets, vaultSecrets } from 'node-env-resolver-aws';

// Synchronous with custom resolver (object syntax)
const config = resolve({
  resolvers: [
    [
      dotenv(),
      {
        PORT: 3000,
        NODE_ENV: ['development', 'production', 'test'] as const,
        DEBUG: false,
        API_KEY: string({ optional: true }),
      },
    ],
  ],
});

// Asynchronous with any resolvers (both sync and async work!)
const config = await resolveAsync({
  resolvers: [
    [
      dotenv(),
      {
        PORT: 3000,
        NODE_ENV: ['development', 'production', 'test'] as const,
        DEBUG: false,
        API_KEY: string({ optional: true }),
      },
    ],
    [awsSecrets(), { DATABASE_URL: url() }],
  ],
});

// Resulting type (fully inferred):
// type config = {
//   PORT: number;
//   NODE_ENV: 'development' | 'production' | 'test';
//   DEBUG: boolean;
//   API_KEY: string | undefined;
//   DATABASE_URL: string;
// }

// Multiple resolvers with options
const config = await resolveAsync({
  resolvers: [
    [processEnv(), { PORT: 3000 }],
    [dotenv(), { DATABASE_URL: postgres() }],
    [awsSecrets(), { API_KEY: string() }],
    [gcpSecrets(), { JWT_SECRET: string() }],
    [vaultSecrets(), { REDIS_URL: redis() }],
  ],
  options: { priority: 'last' },
});

Table of Contents

Basic usage

Required values

If an environment variable is missing and has no default, validation fails:

import { resolve } from 'node-env-resolver';
import { postgres, string } from 'node-env-resolver/validators';

const config = resolve({
  DATABASE_URL: postgres(), // Required PostgreSQL URL
  API_KEY: string(), // Required
});

Default values

Provide a default value to use when the environment variable is not set:

import { resolve } from 'node-env-resolver';

const config = resolve({
  PORT: 3000, // Defaults to 3000
  DEBUG: false, // Defaults to false
  LOG_LEVEL: 'info', // Defaults to 'info'
});

Optional values

Use the {optional: true} option to make a value optional:

import { string, url, number } from 'node-env-resolver/validators';

const config = resolve({
  API_KEY: string({ optional: true }), // string | undefined
  REDIS_URL: url({ optional: true }), // string | undefined
  MAX_RETRIES: number({ optional: true }), // number | undefined
});

Enums

Use arrays for enum validation:

import { resolve } from 'node-env-resolver';

const config = resolve({
  NODE_ENV: ['development', 'production', 'test'] as const,
  LOG_LEVEL: ['debug', 'info', 'warn', 'error'] as const,
});

// TypeScript knows the exact types
config.NODE_ENV; // 'development' | 'production' | 'test'
config.LOG_LEVEL; // 'debug' | 'info' | 'warn' | 'error'

Optional Enums

Make enum values optional using the optional() wrapper or enumOf() function:

import { resolve } from 'node-env-resolver';
import { optional, enumOf } from 'node-env-resolver/validators';

const config = resolve({
  // Required enum (must be set to one of these values)
  NODE_ENV: ['development', 'production', 'test'] as const,

  // Optional enum - Method 1: optional() wrapper (clean syntax)
  PROTOCOL: optional(['http', 'grpc'] as const),

  // Optional enum - Method 2: enumOf() function (explicit)
  LOG_LEVEL: enumOf(['error', 'warn', 'info', 'debug'] as const, {
    optional: true,
  }),

  // Enum with default value
  COMPRESSION: enumOf(['gzip', 'brotli', 'none'] as const, { default: 'gzip' }),
});

// TypeScript infers the correct types:
// config.NODE_ENV: 'development' | 'production' | 'test'
// config.PROTOCOL: 'http' | 'grpc' | undefined
// config.LOG_LEVEL: 'error' | 'warn' | 'info' | 'debug' | undefined
// config.COMPRESSION: 'gzip' | 'brotli' | 'none'

Inferring the config type

Define the schema once and derive its type with InferSimpleSchema:

import { resolve, type InferSimpleSchema } from 'node-env-resolver';
import { port, string } from 'node-env-resolver/validators';

const schema = {
  PORT: port({ default: 8400 }),
  TOKEN: string(),
  NODE_ENV: ['development', 'production'] as const,
};

export type Env = InferSimpleSchema<typeof schema>;
// { PORT: number; TOKEN: string; NODE_ENV: 'development' | 'production' }

export const env: Env = resolve(schema);

Variable Naming Conventions

The library validates environment variable names to prevent shell errors and ensure cross-platform compatibility.

Supported Name Formats

Variable names can use:

  • Letters (both uppercase and lowercase): A-Z, a-z
  • Numbers (not as first character): 0-9
  • Underscores: _

Valid Examples

import { resolve } from 'node-env-resolver';
import { string } from 'node-env-resolver/validators';

const config = resolve({
  PORT: 3000, // ✅ SCREAMING_SNAKE_CASE (traditional)
  port: 3000, // ✅ lowercase
  myApiKey: string(), // ✅ camelCase
  my_api_key: string(), // ✅ snake_case
  API_KEY_V2: string(), // ✅ with numbers (not first char)
  _PRIVATE: string(), // ✅ starting with underscore
});

Invalid Examples

import { resolve } from 'node-env-resolver';
import { string } from 'node-env-resolver/validators';

const config = resolve({
  '123PORT': 3000, // ❌ Starts with number
  'API-KEY': string(), // ❌ Contains hyphen
  'API.KEY': string(), // ❌ Contains dot
  'API KEY': string(), // ❌ Contains space
  'API@KEY': string(), // ❌ Special characters
});

Recommendation: While all formats are supported, SCREAMING_SNAKE_CASE (e.g., DATABASE_URL, API_KEY) is the most common convention for environment variables and ensures maximum compatibility with legacy systems.

Validation pattern: /^[A-Za-z_][A-Za-z0-9_]*$/

Performance & Bundle Size

Lightweight The core library is ~4.3KB gzipped with validation and resolver capabilities - optimized for minimal bundle impact.

import { resolveAsync } from 'node-env-resolver';
import { processEnv, dotenv, json } from 'node-env-resolver/resolvers';
import { postgres, string, redis } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [
    [processEnv(), { PORT: 3000 }],
    [dotenv(), { DATABASE_URL: postgres() }],
    [awsSecrets(), { API_KEY: string() }],
  ],
  // Perfect for complex microservice architectures!
});

Benefits:

  • No artificial limits - use as many resolvers as needed
  • Better type inference - proper schema merging with full TypeScript support
  • Cleaner code - single implementation instead of multiple overloads
  • Future-proof - automatically supports new resolver types

Efficient validation architecture

The library uses a two-tier validation strategy with lazy-loaded validators:

Basic types (~6.3KB core - inline validation):

  • string, number, boolean (with min/max validation)
  • enum (array validation)
  • pattern (regex validation)
  • custom (validator functions)

Advanced types (+1KB when used - lazy-loaded):

  • Database URLs: postgres, mysql, mongodb, redis
  • Web types: http, https, url, email
  • Format types: json, date, timestamp, port

Advanced validators are lazy-loaded only when you use them, keeping the base bundle minimal. All types work both synchronously and asynchronously:

import { resolve, resolveAsync } from 'node-env-resolver';
import { postgres, url } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

// Synchronous - works with all types
const config = resolve({
  PORT: 3000,
  DATABASE_URL: postgres(),
  API_URL: url(),
  NODE_ENV: ['development', 'production'] as const,
});

// Also works with async resolvers
const config = await resolveAsync({
  resolvers: [[awsSecrets(), { DATABASE_URL: postgres(), API_URL: url() }]],
});

What's in the 6.3KB core?

  • Core resolver logic (async/sync resolution)
  • Schema normalization & type coercion
  • Basic validation (string, number, boolean, enum, pattern, custom)
  • Interpolation & policy checking
  • Provenance tracking & error handling

Code Splitting Architecture

The library uses intelligent code splitting to keep the core minimal:

  • Advanced validators (~1KB): Lazy-loaded when using types like url, email, postgres
  • Audit logging (~150 bytes): Lazy-loaded when enableAudit: true
  • dotenv parser (~1.6KB): Separate import from 'node-env-resolver/resolvers'
  • Utility functions (~1KB): Separate import from 'node-env-resolver/utils'

Only the code you actually use gets loaded!

Audit Logging (Lazy Loaded)

Audit logging is lazy-loaded only when enabled, keeping the base bundle minimal:

import { resolve } from 'node-env-resolver';

// Base bundle
const config = resolve({ PORT: 3000 });

// Audit module loaded when enabled
const config = resolve({ PORT: 3000 }, { enableAudit: true });

Optimized Module Structure

Import only what you need for optimal bundle size:

// Core (~6.3KB) - Main API
import { resolve, safeResolve } from 'node-env-resolver';

// Resolvers (separate chunk) - .env file parsing
import { processEnv, dotenv } from 'node-env-resolver/resolvers';

// Utils (separate chunk) - caching & retry logic
import { cached, retry, TTL, awsCache } from 'node-env-resolver/utils';

// Validators (separate chunk) - reusable validation functions
import { url, email } from 'node-env-resolver/validators';

// Integrations (separate packages)
import { resolveZod } from 'node-env-resolver/zod';
import { resolveValibot } from 'node-env-resolver/valibot';
import { awsSecrets } from 'node-env-resolver-aws';

Most apps only need the core (~6.3KB)!

Custom validators

Write your own validation functions:

import { resolve } from 'node-env-resolver';

const isValidPort = (value: string): number => {
  const port = parseInt(value, 10);
  if (isNaN(port) || port < 1 || port > 65535) {
    throw new Error('Port must be between 1 and 65535');
  }
  return port;
};

const isValidEmail = (value: string): string => {
  if (!value.includes('@')) {
    throw new Error('Invalid email address');
  }
  return value.toLowerCase();
};

const config = resolve({
  CUSTOM_PORT: isValidPort,
  ADMIN_EMAIL: isValidEmail,
});

Reusing built-in validators

For advanced use cases, you can import and compose the built-in validation functions:

import { resolve } from 'node-env-resolver';
import { url, email } from 'node-env-resolver/validators';

const validateMarketingUrl = (value: string): string => {
  // Reuse built-in URL validator
  const result = url(value);
  if (!result.valid) {
    throw new Error(result.error || 'Invalid URL');
  }

  // Add custom business logic
  if (!value.includes('marketing.example.com')) {
    throw new Error('Must be a marketing domain URL');
  }

  return value;
};

const config = resolve({
  MARKETING_URL: validateMarketingUrl,
});

Available validation functions: validatePostgres, mysql, mongodb, redis, http, https, url, email, port, number, boolean, json, date, timestamp.

Multiple sources

Load configuration from multiple sources. By default, later sources override earlier ones:

import { resolve, resolveAsync } from 'node-env-resolver';
import { processEnv, dotenv, json } from 'node-env-resolver/resolvers';
import { postgres, string, redis } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

// Async mode - supports both sync and async resolvers
const config = await resolveAsync({
  resolvers: [
    [
      processEnv(),
      {
        PORT: 3000,
        NODE_ENV: ['development', 'production'] as const,
      },
    ],
    [
      dotenv(),
      {
        DATABASE_URL: postgres(),
        API_KEY: string(),
      },
    ],
  ],
});

// Sync mode - supports multiple SYNC resolvers
const config = resolve({
  resolvers: [
    [dotenv(), { PORT: 3000 }],
    [json('config.json'), { DATABASE_URL: postgres() }],
  ],
  // Both resolvers must have loadSync() method
});

// Support for ANY number of resolvers (3, 4, 5, 10+ resolvers!)
const config = await resolveAsync({
  resolvers: [
    [processEnv(), { PORT: 3000 }],
    [dotenv(), { DATABASE_URL: postgres() }],
    [awsSecrets(), { API_KEY: string() }],
  ],
  options: { priority: 'last' },
});

Controlling merge behaviour

Use priority to control how resolvers merge values (works with both sync and async modes):

import { resolve, resolveAsync } from 'node-env-resolver';
import { processEnv, dotenv, json } from 'node-env-resolver/resolvers';
import { postgres } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

// priority: 'last' (default) - later resolvers override earlier ones
const config = await resolveAsync({
  resolvers: [
    [processEnv(), { DATABASE_URL: postgres() }],
    [awsSecrets(), { DATABASE_URL: postgres() }],
  ],
  // AWS wins
});

// priority: 'first' - earlier resolvers take precedence
const config = await resolveAsync({
  resolvers: [
    [dotenv(), { DATABASE_URL: postgres() }],
    [awsSecrets(), { DATABASE_URL: postgres() }],
  ],
  options: { priority: 'first' },
  // dotenv wins
});

// Also works with sync resolve()
const config = resolve({
  resolvers: [
    [dotenv(), { DATABASE_URL: postgres() }],
    [json('config.json'), { DATABASE_URL: postgres() }],
  ],
  options: { priority: 'first' },
  // dotenv wins
});

This is useful for development workflows where local overrides should take precedence over cloud secrets.

Performance optimizations

The library includes two automatic performance optimizations:

1. Early termination with priority: 'first'

When using priority: 'first', resolvers are called sequentially, but execution stops early once all required environment variables are satisfied:

import { resolveAsync } from 'node-env-resolver';
import { dotenv } from 'node-env-resolver/resolvers';
import { postgres, string } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [
    [dotenv(), { DATABASE_URL: postgres(), API_KEY: string(), PORT: 3000 }],
    [awsSecrets(), { DATABASE_URL: postgres(), API_KEY: string(), PORT: 3000 }],
  ],
  options: { priority: 'first' },
});
// If dotenv() provides all required keys, awsSecrets() is never called!

This is particularly valuable in development where:

  • Local .env file contains all needed variables
  • Expensive remote resolver calls (AWS Secrets Manager, Parameter Store, GCP Secret Manager) are skipped
  • Startup time is significantly reduced

What counts as "satisfied"?

  • Only required keys (no optional: true, no default value) trigger early termination
  • Optional variables and variables with defaults do not prevent calling remaining resolvers

2. Parallel execution with priority: 'last'

When using priority: 'last' (the default), all resolvers are called in parallel for maximum performance:

import { resolveAsync } from 'node-env-resolver';
import { postgres, string } from 'node-env-resolver/validators';
import { awsSecrets, awsSsm } from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [
    [awsSecrets(), { DATABASE_URL: postgres() }], // 100ms
    [awsSsm(), { API_KEY: string() }], // 100ms
  ],
  // Default: priority: 'last'
});
// Total time: ~100ms (parallel) instead of ~300ms (sequential)

Since priority: 'last' means "last write wins", the order doesn't matter for conflict resolution, so all resolvers can run concurrently.

Use cases:

  • Production environments loading from multiple remote secret stores
  • Fetching different variables from different sources
  • Significant speedup when resolvers have network latency

Safe error handling

Like Zod's safeParse(), use safeResolve() to get a result object instead of throwing:

import { safeResolve, safeResolveAsync } from 'node-env-resolver';
import { number, postgres } from 'node-env-resolver/validators';

// Synchronous safe resolve
const result = safeResolve({
  PORT: number(),
  DATABASE_URL: postgres(),
});

// Asynchronous safe resolve
const result = await safeResolveAsync({
  resolvers: [
    [processEnv(), { PORT: number() }],
    [awsSecrets(), { DATABASE_URL: postgres() }],
  ],
});

if (result.success) {
  // Use result.data with full type safety
  console.log(result.data.PORT);
} else {
  // Handle error gracefully
  console.error(result.error);
  process.exit(1);
}

When validation fails, result.issues lists each problem so you can map it to a field without parsing strings:

// result.issues: { key: string; reason: 'missing' | 'invalid' | 'policy'; message: string }[]
for (const issue of result.issues ?? []) {
  console.error(`${issue.key} (${issue.reason}): ${issue.message}`);
}

resolve() and resolveAsync() throw an EnvValidationError carrying the same issues:

import { resolve, EnvValidationError } from 'node-env-resolver';

try {
  resolve(schema);
} catch (error) {
  if (error instanceof EnvValidationError) {
    console.error(error.issues);
  }
  throw error;
}

All functions have safe variants:

  • resolve() → safeResolve() (both synchronous)
  • resolveAsync() → safeResolveAsync() (both async)

Synchronous resolution

resolve() is synchronous by default when reading from process.env:

import { resolve } from 'node-env-resolver';
import { string, url, postgres } from 'node-env-resolver/validators';

// Synchronous - no await needed, works with ALL types
const config = resolve({
  PORT: 3000,
  NODE_ENV: ['development', 'production'] as const,
  API_KEY: string(),
  API_URL: url(), // Advanced types work synchronously!
  DATABASE_URL: postgres(),
  DEBUG: false,
});

resolveAsync() is async when using custom resolvers:

import { resolveAsync } from 'node-env-resolver';
import { processEnv, dotenv } from 'node-env-resolver/resolvers';
import { postgres, url } from 'node-env-resolver/validators';

// Async - await required when using custom resolvers
const config = await resolveAsync({
  resolvers: [
    [processEnv(), { PORT: 3000 }],
    [dotenv(), { DATABASE_URL: postgres(), API_URL: url() }],
  ],
});

Advanced features

CLI

The ner CLI ships with the package. Install globally or run via npx ner.

ner scan — secret scanner

Scans files for hardcoded secrets: Stripe keys, GitHub tokens, JWTs, DB connection strings, AWS keys, private keys and more. The scan includes dotfiles such as .env.local and .npmrc, because .gitignore does not stop an agent or a git add -f from reading them. Each finding reports file, line, column and type. Exit codes: 0 clean, 1 findings, 2 usage error.

# Scan a directory
ner scan src/

# Scan the staged blobs that will be committed (pre-commit hooks)
ner scan --staged

# Show the matching line, with every secret replaced by [REDACTED]
ner scan --context src/

# Machine-readable output
ner scan --format json .

# Exclude patterns
ner scan --ignore "fixtures" --ignore "\.test\." src/

Pre-commit hook setup:

echo 'ner scan --staged' >> .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

ner run — run with resolved env vars

Loads a .env file, resolves any reference URIs, then spawns your command. No code changes needed in your app.

# Basic usage
ner run -- node server.js

# Use a different env file
ner run --env .env.staging -- node deploy.js

# Skip reference resolution (equivalent to node --env-file)
ner run --no-resolve -- node server.js

# Warn if .env contains hardcoded secrets before running
ner run --scan -- node server.js

Reference resolution — the key differentiator from node --env-file:

# .env
DATABASE_URL=aws-sm://prod/database-credentials
API_KEY=aws-ssm:///prod/payments/api-key
# ner run resolves these before process start:
ner run -- node server.js
# → DATABASE_URL and API_KEY injected as their real values

Requires node-env-resolver-aws for aws-sm:// and aws-ssm:// support:

npm install node-env-resolver-aws @aws-sdk/client-secrets-manager @aws-sdk/client-ssm

ner run passes the resolved values and the parent environment to the child process. Launch your app with it, not a coding agent. See Coding agents.

ner describe: schema manifest

ner describe prints names, types, requirements, descriptions and fake examples from your schema. It reads no values and calls no providers, so you can commit the output and hand it to a coding agent.

// env.schema.ts: export the schema you pass to resolve()
import { secret, url, port, withMeta } from 'node-env-resolver/validators';

export const schema = {
  STRIPE_KEY: withMeta(secret(), { description: 'Stripe API key' }),
  API_URL: withMeta(url(), { description: 'Upstream API', example: 'https://api.example.com' }),
  PORT: port({ default: 3000 }),
};
ner describe                                    # table
ner describe --format json > env.manifest.json
ner describe --format dotenv > .env.example

The CLI loads env.schema.{ts,mts,js,mjs} or --schema <file>, using the schema export or the default export. .ts files need Node 22.18+ or a loader such as tsx.

In code, use describeSchema(schema), toDotenvExample(schema) and fakeEnv(schema). fakeEnv returns fake values for tests that run without cloud credentials. It validates each value and throws if a constraint such as number({ min: 10 }) rejects the generated one. Supply your own with withMeta(validator, { example }).

import { resolve, fakeEnv } from 'node-env-resolver';
import { fromObject } from 'node-env-resolver/resolvers';

const config = resolve({ resolvers: [[fromObject(fakeEnv(schema)), schema]] });

ner check: validate without printing values

ner check            # offline: reference URIs show up as "deferred"
ner check --agent    # JSON: { ok, issues: [{ key, code, message, sensitive }], deferred }
ner check --resolve  # online: resolve aws-sm:// and friends first (needs credentials)

Output contains key names, issue codes and the failed type check, plus allowed values for enums. It leaves out values, fragments, lengths, fingerprints and validator messages. --resolve resolves schema keys that use a known reference scheme (aws-sm, aws-ssm, op, bws, doppler, infisical, vault) and treats plain URLs as values. A file() variable with a secretsDir shows up as deferred. Exit codes: 0 valid, 1 missing or invalid variables, 2 usage or schema error.

Sensitive values

The library masks sensitive values in debug views and redacts them in runtime console and HTTP protection, including numbers and json() fields. The Next.js and Vite adapters reject them from client config, by validator and by resolved value. A value counts as sensitive when:

  1. You mark it: secret(), file(), postgres(), mysql(), mongodb(), redis(), or withMeta(validator, { sensitive: true }).
  2. It contains a marked value: for example a URL built by interpolation or withComputed(). Encodings such as base64 fall outside this check.
  3. Its key name matches DEFAULT_SENSITIVE_PATTERNS (*KEY*, *TOKEN*, *SECRET*, *PASSWORD* and others).

withMeta(validator, { sensitive: false }) turns off the key-name match for ner describe and ner check. Debug views and runtime redaction keep it on.

Coding agents

A coding agent reads whatever its process can reach: environment variables, repo files including git-ignored .env files, and command output. Give the agent the schema and the right to use credentials, and keep the credential values on the host.

  • Share context: commit ner describe --format json output. Let agents run ner check --agent and tests against fakeEnv(schema).
  • Keep plaintext out of the workspace: put reference URIs (aws-sm://...) in .env. Docker documents that a sandboxed agent can read ignored and untracked files in the mounted repo.
  • Launch agents directly: an agent started through ner run inherits every resolved secret.

Docker Sandboxes

Docker Sandboxes run agents in a microVM. A proxy on the host holds the real credential and the agent sees a placeholder. The proxy swaps in the real value on requests to hosts you allow. Point it at the secret your reference already names, and the value stays on the host:

# .env, shared with the sandbox: references only
STRIPE_KEY=aws-sm://prod/stripe-key

# On the host. The sandbox sees STRIPE_KEY=<placeholder>; the proxy injects
# the real value on requests to api.stripe.com.
sbx secret set-custom \
  --host api.stripe.com \
  --env STRIPE_KEY \
  --command 'aws secretsmanager get-secret-value --secret-id prod/stripe-key --query SecretString --output text'

# 1Password references work for built-in services
sbx secret set anthropic --ref 'op://Work/Anthropic/credential'

Plan for two limits:

  • The agent can make any request the credential allows to the allowed hosts. Scope credentials and host lists tightly.
  • The proxy swaps placeholders in HTTP(S) traffic. Database drivers and SDKs that sign requests locally need the raw value, so run them against fakeEnv(schema) or local services and keep real integration tests outside the sandbox.

Runtime Protection

Prevent accidental secret leaks in console output and HTTP responses.

One-call setup

import { protect } from 'node-env-resolver/runtime';

const config = resolve({ DATABASE_URL: postgres(), API_KEY: string() });

// Patches all console methods to redact secrets automatically
const unprotect = protect(config);

console.log('DB:', config.DATABASE_URL); // DB: [REDACTED]
console.log('Key:', config.API_KEY); // Key: [REDACTED]

// Restore originals when done (e.g. in tests)
unprotect();

Options:

protect(config, {
  console: {
    methods: ['log', 'error', 'warn'], // which methods to patch (default: all)
    enabled: true,
  },
  // console: false  — disable console patching entirely
});

Response middleware (Express / Connect / Hono)

Intercepts res.end() to scan outgoing responses for secret leaks:

import { createResponseMiddleware } from 'node-env-resolver/runtime';

app.use(createResponseMiddleware(config, { mode: 'redact' }));
// mode: 'redact' — replace secrets in response body
// mode: 'warn'   — log a warning, pass body through (default in production)
// mode: 'throw'  — throw on any detected leak (default in development)

Edge runtime (Cloudflare Workers, Deno Deploy)

import { wrapFetchResponse } from 'node-env-resolver/runtime';

export default {
  async fetch(request: Request) {
    const response = await handler(request);
    return wrapFetchResponse(response, config, { mode: 'redact' });
  },
};

Low-level primitives

import {
  createRedactor,
  extractSensitiveValues,
  createConsoleRedactor,
  scanBodyForSecrets,
} from 'node-env-resolver/runtime';

// Build a redactor from a config object
const secrets = extractSensitiveValues(config); // Map of sensitive key→value pairs
const redactor = createRedactor(secrets);
redactor.redactString('key is sk_live_abc123'); // 'key is [REDACTED]'
redactor.maskValue('super-secret'); // 'su****et'
redactor.createFingerprint('secret'); // 'sha256:a1b2c3d4'

// Scan a response body manually
const { safe, redacted } = scanBodyForSecrets(body, config);

// Custom sensitive key patterns
const secrets2 = extractSensitiveValues(config, {
  sensitiveKeys: ['MY_PREFIX'],
});

Computed fields

Derive properties from resolved configuration:

import { resolve } from 'node-env-resolver';
import { withComputed } from 'node-env-resolver/utils';

const config = resolve({
  HOST: 'localhost',
  PORT: 3000,
  SSL_ENABLED: false,
  NODE_ENV: ['development', 'production'] as const,
});

// Add computed properties
const appConfig = withComputed(config, {
  // Build URLs from components
  url: (c) => `${c.SSL_ENABLED ? 'https' : 'http'}://${c.HOST}:${c.PORT}`,

  // Environment checks
  isProd: (c) => c.NODE_ENV === 'production',
  isDev: (c) => c.NODE_ENV === 'development',

  // Complex derived config
  serverOptions: (c) => ({
    host: c.HOST,
    port: c.PORT,
    cors: c.NODE_ENV !== 'production',
    compression: c.NODE_ENV === 'production',
  }),
});

console.log(appConfig.url); // 'http://localhost:3000'
console.log(appConfig.isProd); // false
console.log(appConfig.serverOptions); // { host: 'localhost', ... }

Common patterns:

// Build database connection URLs
withComputed(config, {
  databaseUrl: (c) =>
    `postgres://${c.DB_USER}:${c.DB_PASSWORD}@${c.DB_HOST}:${c.DB_PORT}/${c.DB_NAME}`,
});

// Derive API endpoints
withComputed(config, {
  endpoints: (c) => ({
    users: `${c.API_BASE_URL}/${c.API_VERSION}/users`,
    posts: `${c.API_BASE_URL}/${c.API_VERSION}/posts`,
  }),
});

// Feature flags based on environment
withComputed(config, {
  features: (c) => ({
    analytics: c.ENABLE_ANALYTICS && c.NODE_ENV === 'production',
    debug: c.NODE_ENV === 'development',
  }),
});

Bundle size: ~200 bytes (included in utils)

Zod integration

Use Zod schemas for powerful validation:

import { resolveZod } from 'node-env-resolver/zod';
import * as z from 'zod'; // ✅ Recommended import pattern (better tree-shaking)

const schema = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DEBUG: z.coerce.boolean().optional(),
});

const config = await resolveZod(schema);
// config.PORT is number, config.DATABASE_URL is string, etc.

Safe error handling with field-level errors:

import { safeResolveZod } from 'node-env-resolver/zod';
import * as z from 'zod';

const schema = z.object({
  PORT: z.coerce.number(),
  DATABASE_URL: z.string().url(),
});

const result = await safeResolveZod(schema);

if (result.success) {
  console.log(result.data.PORT);
} else {
  console.error(result.error); // Summary message
  result.issues.forEach((issue) => {
    // Field-level details: path, message, code
    console.log(`${issue.path.join('.')}: ${issue.message}`);
  });
}

Available functions:

  • resolveZod() - Async, throws on error
  • safeResolveZod() - Async, returns { success, data/error, issues }
  • resolveSyncZod() - Sync, throws on error
  • safeResolveSyncZod() - Sync, returns { success, data/error, issues }

Valibot integration

Use Valibot for lightweight, modular validation:

import { resolveValibot } from 'node-env-resolver/valibot';
import * as v from 'valibot'; // User imports valibot separately

const schema = v.object({
  PORT: v.pipe(v.string(), v.transform(Number)),
  DATABASE_URL: v.pipe(v.string(), v.url()),
  NODE_ENV: v.picklist(['development', 'production', 'test']),
  DEBUG: v.optional(v.pipe(v.string(), v.transform(Boolean))),
});

const config = await resolveValibot(schema);

Safe error handling with unified error format:

import { safeResolveValibot } from 'node-env-resolver/valibot';
import * as v from 'valibot';

const schema = v.object({
  PORT: v.pipe(v.string(), v.transform(Number)),
  DATABASE_URL: v.pipe(v.string(), v.url()),
});

const result = await safeResolveValibot(schema);

if (result.success) {
  console.log(result.data.PORT);
} else {
  console.error(result.error); // Summary message
  result.issues.forEach((issue) => {
    // Same format as Zod - consistent across validators!
    console.log(`${issue.path.join('.')}: ${issue.message}`);
  });
}

Available functions:

  • resolveValibot() - Async, throws on error
  • safeResolveValibot() - Async, returns { success, data/error, issues }
  • resolveSyncValibot() - Sync, throws on error
  • safeResolveSyncValibot() - Sync, returns { success, data/error, issues }

Why unified errors? Switch between Zod and Valibot without changing error handling code!

Custom resolvers

Create resolvers to load configuration from any source:

import { resolveAsync, type Resolver } from 'node-env-resolver';
import { processEnv } from 'node-env-resolver/resolvers';
import { string } from 'node-env-resolver/validators';

const databaseResolver: Resolver = {
  name: 'database',
  async load() {
    const rows = await db.query('SELECT key, value FROM config');
    return Object.fromEntries(rows.map((r) => [r.key, r.value]));
  },
};

const config = await resolveAsync({
  resolvers: [
    [processEnv(), { PORT: 3000 }],
    [databaseResolver, { API_KEY: string() }],
  ],
});

Built-in process-env references

Use process-env://... when one config value should resolve from another environment variable. This keeps reference-style config consistent across backends such as process-env://, aws-sm://, or vault://, without requiring a custom handler. process-env is built in, so no references object is required.

import { resolve } from 'node-env-resolver';
import { string } from 'node-env-resolver/validators';

process.env.API_KEY = 'real-secret-value';
process.env.OPENAI_API_KEY = 'process-env://API_KEY';

const config = resolve(
  {
    OPENAI_API_KEY: string(),
  },
);

console.log(config.OPENAI_API_KEY); // real-secret-value

This keeps OPENAI_API_KEY from containing the raw secret directly, but it does not protect secrets from code that can already read process.env. For stronger secret isolation, prefer an external secret backend such as AWS Secrets Manager or Vault.

OPENAI_API_KEY=process-env://API_KEY

This is useful when you want OPENAI_API_KEY to point at API_KEY without duplicating the raw value in the config entry.

AWS Secrets Manager & SSM

Install the AWS package:

npm install node-env-resolver-aws @aws-sdk/client-secrets-manager @aws-sdk/client-ssm

Reference URIs (recommended)

Store references in your .env and resolve at startup — no AWS SDK imports needed in app code:

# .env
DATABASE_URL=aws-sm://prod/database-credentials
DB_PASSWORD=aws-sm://prod/database#password   # extract a JSON key
API_KEY=aws-ssm:///prod/payments/api-key
import { resolveAsync } from 'node-env-resolver';
import { dotenv } from 'node-env-resolver/resolvers';
import { postgres, string } from 'node-env-resolver/validators';
import {
  createAwsSecretHandler,
  createAwsSsmHandler,
} from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [
    [
      dotenv(),
      { DATABASE_URL: postgres(), DB_PASSWORD: string(), API_KEY: string() },
    ],
  ],
  references: {
    handlers: {
      'aws-sm': createAwsSecretHandler({ region: 'us-east-1' }),
      'aws-ssm': createAwsSsmHandler({ region: 'us-east-1' }),
    },
  },
});

Or use the pre-configured singletons (picks up AWS_REGION from environment):

import { awsSecretHandler, awsSsmHandler } from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [[dotenv(), schema]],
  references: {
    handlers: { 'aws-sm': awsSecretHandler, 'aws-ssm': awsSsmHandler },
  },
});

Each createAwsSecretHandler() / createAwsSsmHandler() call creates its own SDK client, so multi-region setups work correctly without interference.

Resolver pattern (bulk load)

Loads all keys from a secret or parameter path as a resolver:

import { resolveAsync } from 'node-env-resolver';
import { processEnv } from 'node-env-resolver/resolvers';
import { postgres, string } from 'node-env-resolver/validators';
import { awsSecrets, awsSsm } from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [
    [processEnv(), { PORT: 3000 }],
    [awsSecrets({ secretId: 'app/secrets' }), { DATABASE_URL: postgres() }],
    [awsSsm({ path: '/app/config' }), { API_KEY: string() }],
  ],
});

See the AWS package documentation for credentials, caching, and best practices.

TTL caching

Cache expensive operations like AWS API calls:

import { resolveAsync } from 'node-env-resolver';
import { cached, TTL } from 'node-env-resolver/utils';
import { postgres, string } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

export const getConfig = async () => {
  return await resolveAsync({
    resolvers: [
      [
        cached(awsSecrets({ secretId: 'app/secrets' }), { ttl: TTL.minutes5 }),
        {
          DATABASE_URL: postgres(),
          API_KEY: string(),
        },
      ],
    ],
  });
};

// Call getConfig() in your handlers
app.get('/api/data', async (req, res) => {
  const config = await getConfig(); // Fast after first call
});

Performance:

  • First call: ~200ms (loads from AWS)
  • Subsequent calls (< 5min): <1ms (cached)
  • After 5min: ~200ms (refresh from AWS)

Important: Call resolve() every time. The cached() wrapper handles the caching.

Polling remote config

Use poll() when you need to observe changes over time, such as re-reading AWS Secrets Manager on a schedule. It is polling, not push-based events, so it fits cases where you want periodic refresh and change detection.

import { resolveAsync } from 'node-env-resolver';
import { cached, poll } from 'node-env-resolver/utils';
import { postgres, string } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

export const getConfig = async () => {
  return resolveAsync({
    resolvers: [
      [
        cached(awsSecrets({ secretId: 'app/secrets' }), { ttl: 10_000 }),
        {
          DATABASE_URL: postgres(),
          API_KEY: string(),
        },
      ],
    ],
  });
};

const watcher = poll(getConfig, {
  intervalMs: 10_000,
  onChange: (next, previous) => {
    console.log('Config updated', { previous, next });
  },
  onError: (error) => {
    console.error('Polling failed', error);
  },
});

// Read the last loaded value whenever you need it.
console.log(watcher.getCurrent());

// Stop polling during shutdown.
process.on('SIGINT', () => watcher.stop());

When to use this:

  • You need to detect config changes without restarting the process
  • You want a small, explicit polling loop instead of an event system
  • You are reading from a remote source that changes on a schedule

When not to use this:

  • You only need startup-time validation
  • You want push notifications from a backend that already supports events
  • You are on the Next.js config path, which must stay synchronous

Advanced caching options:

import { cached, TTL } from 'node-env-resolver/utils';

// Stale-while-revalidate: serve stale data while refreshing in background
const resolver = cached(awsSecrets(), {
  ttl: TTL.minutes5, // Fresh for 5 minutes
  maxAge: TTL.hour, // Force refresh after 1 hour
  staleWhileRevalidate: true, // Serve stale data during refresh
});

// Custom cache key for debugging
const resolver = cached(awsSecrets(), {
  ttl: TTL.minutes15,
  key: 'prod-secrets-v2',
});

API Reference

Function variants

| Function | Behaviour | Use case | | -------------------- | ---------------------------- | -------------------------------------------- | | resolve() | Sync, throws on error | Most applications (reading from process.env) | | safeResolve() | Sync, returns result object | Graceful error handling | | resolveAsync() | Async, throws on error | Multiple sources (dotenv, AWS, etc.) | | safeResolveAsync() | Async, returns result object | Multiple sources with error handling |

Resolver API Reference

Import resolver functions from 'node-env-resolver/resolvers':

import {
  string,
  number,
  boolean,
  url,
  email,
  port,
  json,
  postgres,
  mysql,
  mongodb,
  redis,
  http,
  https,
  date,
  timestamp,
  duration,
  file,
} from 'node-env-resolver/validators';

| Syntax | Type | Description | | ----------------------------------------------- | ------------------------- | --------------------------------------------------- | | string() | string | Required string (empty strings rejected by default) | | string({optional: true}) | string \| undefined | Optional string | | number() | number | Required number (coerced from string) | | number({optional: true}) | number \| undefined | Optional number | | boolean() | boolean | Required boolean (coerced from string) | | boolean({optional: true}) | boolean \| undefined | Optional boolean | | url() | string | Validated URL (returns string) | | url({optional: true}) | string \| undefined | Optional URL | | email() | string | Validated email address | | port() | number | Validated port number (1-65535) | | json() | unknown | Parsed JSON (returns object/array) | | postgres() or postgresql() | string | Validated PostgreSQL URL | | mysql() | string | Validated MySQL connection string | | mongodb() | string | Validated MongoDB connection string | | redis() | string | Validated Redis connection string | | http() | string | HTTP or HTTPS URL | | https() | string | HTTPS-only URL | | date() | string | Validated ISO 8601 date | | timestamp() | number | Validated Unix timestamp | | duration() | number | Time duration (5s, 2m, 1h → milliseconds) | | file() | string | Read content from file path | | 3000 | number | Number with default value | | false | boolean | Boolean with default value | | 'defaultValue' | string | String with default value | | ['a', 'b'] as const | 'a' \| 'b' | Required enum (requires as const) | | optional(['a', 'b'] as const) | 'a' \| 'b' \| undefined | Optional enum (clean syntax) | | enumOf(['a', 'b'] as const) | 'a' \| 'b' | Required enum (explicit function) | | enumOf(['a', 'b'] as const, {optional: true}) | 'a' \| 'b' \| undefined | Optional enum (explicit function) | | enumOf(['a', 'b'] as const, {default: 'a'}) | 'a' \| 'b' | Enum with default value | | oneOf(['a', 'b'] as const) | 'a' \| 'b' | Alias for enumOf (backward compatible) |

Configuration Options

All resolve() functions accept an optional options parameter to control behaviour:

// Single source (process.env) - options as second parameter
const config = resolve(schema, {
  interpolate: false,
  strict: true,
  policies: {...},
  enableAudit: true
});

// Multiple sources - options in config object
const config = await resolveAsync({
  resolvers: [
    [resolver1, schema],
    [resolver2, schema]
  ],
  options: {
    interpolate: false,
    strict: true,
    policies: {...},
    enableAudit: true,
    priority: 'last'
  }
});

Note: The resolvers option has been removed. For single source resolution, use resolve() directly (defaults to process.env). For multiple sources, use resolveAsync() syntax.

interpolate

What: Enables variable interpolation using ${VAR_NAME} syntax.

When: Use when environment variables reference other variables.

Why: Keeps configuration DRY and maintainable.

Default: true in resolveAsync(), false in resolve()

import { resolve } from 'node-env-resolver';
import { url } from 'node-env-resolver/validators';

// With interpolation
process.env.BASE_URL = 'https://api.example.com';
process.env.API_ENDPOINT = '${BASE_URL}/v1';

const config = resolve(
  {
    BASE_URL: url(),
    API_ENDPOINT: url(),
  },
  {
    interpolate: true,
  },
);

// config.API_ENDPOINT === 'https://api.example.com/v1'

strict

What: Controls whether resolver failures stop the entire resolution process.

When:

  • strict: true (default) - Production environments where you want fail-fast behaviour
  • strict: false - Graceful degradation when some sources might be unavailable

Why:

  • true: Ensures all resolvers work correctly (reliability)
  • false: Allows partial success when some resolvers fail (availability)

Default: true

const flakyResolver = {
  name: 'external-api',
  async load() {
    throw new Error('Service unavailable');
  },
};

// ❌ Throws immediately
await resolveAsync({
  resolvers: [
    [flakyResolver, schema],
    [processEnv(), schema],
  ],
  options: { strict: true }, // default
});

// ✅ Continues with processEnv()
await resolveAsync({
  resolvers: [
    [flakyResolver, schema],
    [processEnv(), schema],
  ],
  options: { strict: false }, // graceful degradation
});

Note: In sync mode with strict: true, resolvers without loadSync() method will throw errors.

priority

What: Controls merge strategy when multiple resolvers provide the same variable.

When:

  • priority: 'last' (default) - Production: cloud secrets override local env
  • priority: 'first' - Development: local overrides override cloud secrets

Why: Different environments need different precedence rules.

Default: 'last'

import { resolveAsync } from 'node-env-resolver';
import { processEnv, dotenv } from 'node-env-resolver/resolvers';
import { postgres } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

// Production: AWS secrets override process.env
await resolveAsync({
  resolvers: [
    [processEnv(), { DATABASE_URL: postgres() }],
    [awsSecrets(), { DATABASE_URL: postgres() }],
  ],
  // priority: 'last' (default) - AWS wins
});

// Development: Local .env overrides cloud
await resolveAsync({
  resolvers: [
    [dotenv(), { DATABASE_URL: postgres() }],
    [awsSecrets(), { DATABASE_URL: postgres() }],
  ],
  options: { priority: 'first' }, // dotenv wins
});

See: "Controlling merge behaviour" section above for details.

policies

What: Security policies to enforce where variables can be loaded from.

When: Use in production to enforce security requirements.

Why: Prevent accidental use of .env files or ensure secrets come from secure sources.

Default: undefined (no policies enforced)

await resolveAsync({
  resolvers: [
    [processEnv(), schema],
    [awsSecrets(), schema],
  ],
  options: {
    policies: {
      // Block .env in production (default behaviour)
      allowDotenvInProduction: false,

      // Force secrets to come from AWS
      enforceAllowedSources: {
        DATABASE_PASSWORD: ['aws-secrets'],
        API_KEY: ['aws-secrets'],
      },
    },
  },
});

See: "Security Policies" section below for complete documentation.

enableAudit

What: Enables audit logging to track where each variable was loaded from.

When:

  • Production monitoring and compliance
  • Debugging configuration issues
  • Security audits

Why: Track configuration sources for compliance and troubleshooting.

Default: false (disabled in development), automatically true in production (NODE_ENV === 'production')

await resolveAsync({
  resolvers: [
    [processEnv(), schema],
    [awsSecrets(), schema],
  ],
  options: { enableAudit: true }, // Explicitly enable in development
});

const logs = getAuditLog();
// [
//   { type: 'env_loaded', key: 'DATABASE_URL', source: 'aws-secrets', ... },
//   { type: 'validation_success', ... }
// ]

See: "Audit Logging" section below for complete documentation.

Complete Interface

interface ResolveOptions {
  interpolate?: boolean; // Variable interpolation (default: true in .async())
  strict?: boolean; // Fail-fast behaviour (default: true)
  priority?: 'first' | 'last'; // Merge strategy (default: 'last')
  policies?: PolicyOptions; // Security policies (default: undefined)
  enableAudit?: boolean; // Audit logging (default: auto in production)
  secretsDir?: string; // Base directory for file secrets (Docker/K8s)
  preventProcessEnvWrite?: boolean; // Keep resolved values out of process.env (default: true)
}

interface PolicyOptions {
  allowDotenvInProduction?: boolean | string[]; // .env in production control
  enforceAllowedSources?: Record<string, string[]>; // Source restrictions
}

Framework examples

Express

import express from 'express';
import { resolve } from 'node-env-resolver';
import { postgres, string } from 'node-env-resolver/validators';

const config = resolve({
  PORT: 3000,
  DATABASE_URL: postgres(),
  SESSION_SECRET: string(),
});

const app = express();
app.listen(config.PORT);

Next.js

// env.mjs
import { resolve } from 'node-env-resolver/nextjs';
import { postgres, string, url } from 'node-env-resolver/validators';

export const env = resolve({
  server: {
    DATABASE_URL: postgres(),
    API_SECRET: string(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: url(),
  },
});

AWS Lambda

import { resolveAsync } from 'node-env-resolver';
import { cached, TTL } from 'node-env-resolver/utils';
import { postgres } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

const getConfig = async () => {
  return await resolveAsync({
    resolvers: [
      [
        cached(awsSecrets({ secretId: 'lambda/config' }), {
          ttl: TTL.minutes5,
        }),
        {
          DATABASE_URL: postgres(),
        },
      ],
    ],
  });
};

export const handler = async (event) => {
  const config = await getConfig();
  // Use config
};

If the values arrive as an object (e.g. Cloudflare Workers env, or a Lambda event/context you've merged), resolve from it directly with fromObject():

import { resolve } from 'node-env-resolver';
import { fromObject } from 'node-env-resolver/resolvers';
import { string } from 'node-env-resolver/validators';

export default {
  async fetch(request: Request, env: Record<string, string | undefined>) {
    const config = resolve({
      resolvers: [[fromObject(env), { API_KEY: string() }]],
    });
    // ...
  },
};

Security Policies

Control where environment variables can be loaded from to enforce security requirements.

Policy: allowDotenvInProduction

By default, .env files are completely blocked in production for security. Production platforms (Vercel, AWS, Docker) inject environment variables via process.env, NOT .env files.

Default behavior (secure):

import { resolveAsync } from 'node-env-resolver';
import { dotenv } from 'node-env-resolver/resolvers';
import { postgres } from 'node-env-resolver/validators';

// In production (NODE_ENV=production)
const config = await resolveAsync({
  resolvers: [
    [
      dotenv(),
      {
        DATABASE_URL: postgres(),
      },
    ],
  ],
});
// ❌ Throws: "DATABASE_URL cannot be sourced from .env files in production"

Allow all .env variables (NOT recommended):

import { resolveAsync } from 'node-env-resolver';
import { dotenv } from 'node-env-resolver/resolvers';
import { postgres } from 'node-env-resolver/validators';

const config = await resolveAsync({
  resolvers: [
    [
      dotenv(),
      {
        DATABASE_URL: postgres(),
      },
    ],
  ],
  options: {
    policies: {
      allowDotenvInProduction: true, // Allow all (risky!)
    },
  },
});

Allow specific variables only (recommended if needed):

import { resolveAsync } from 'node-env-resolver';
import { dotenv } from 'node-env-resolver/resolvers';
import { postgres } from 'node-env-resolver/validators';

const config = await resolveAsync({
  resolvers: [
    [
      dotenv(),
      {
        PORT: 3000,
        DATABASE_URL: postgres(),
      },
    ],
  ],
  options: {
    policies: {
      allowDotenvInProduction: ['PORT'], // Only PORT allowed from .env
      // DATABASE_URL must come from process.env or cloud resolvers
    },
  },
});

Policy: enforceAllowedSources

Restrict sensitive variables to specific resolvers (e.g., force secrets to come from AWS):

import { resolveAsync } from 'node-env-resolver';
import { processEnv } from 'node-env-resolver/resolvers';
import { string } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [
    [
      processEnv(),
      {
        PORT: 3000,
      },
    ],
    [
      awsSecrets({ secretId: 'prod/secrets' }),
      {
        DATABASE_PASSWORD: string(),
        API_KEY: string(),
      },
    ],
  ],
  options: {
    policies: {
      enforceAllowedSources: {
        DATABASE_PASSWORD: ['aws-secrets'], // Must come from AWS
        API_KEY: ['aws-secrets'], // Must come from AWS
        // PORT not restricted - can come from any resolver
      },
    },
  },
});

Use case: Ensure production secrets only come from AWS Secrets Manager, never from .env or process.env:

import { resolveAsync } from 'node-env-resolver';
import { processEnv } from 'node-env-resolver/resolvers';
import { string } from 'node-env-resolver/validators';
import { awsSecrets } from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [
    [processEnv(), {}],
    [
      awsSecrets({ secretId: 'prod/db' }),
      {
        DATABASE_PASSWORD: string(),
        STRIPE_SECRET: string(),
      },
    ],
  ],
  options: {
    policies: {
      enforceAllowedSources: {
        DATABASE_PASSWORD: ['aws-secrets'],
        STRIPE_SECRET: ['aws-secrets'],
      },
    },
  },
});

// ✅ If secrets come from AWS → Success
// ❌ If secrets come from process.env → Throws policy violation error

PolicyOptions Interface

interface PolicyOptions {
  /**
   * Control loading from .env files in production.
   *
   * - undefined (default): .env files completely ignored in production
   * - true: Allow all vars from .env in production (NOT recommended)
   * - string[]: Allow only specific vars from .env in production
   */
  allowDotenvInProduction?: boolean | string[];

  /**
   * Restrict variables to specific resolvers.
   *
   * Example: { DATABASE_PASSWORD: ['aws-secrets'] }
   */
  enforceAllowedSources?: Record<string, string[]>;
}

Reference-first hardening preset

For incident response hardening, keep secret values out of Vercel env vars. Store references such as aws-sm://prod/stripe-key and resolve the value at runtime via reference handlers.

import { resolveAsync, strictReferenceResolveOptions } from 'node-env-resolver';
import { processEnv } from 'node-env-resolver/resolvers';
import { string } from 'node-env-resolver/validators';
import { createAwsSecretHandler } from 'node-env-resolver-aws';

const config = await resolveAsync({
  resolvers: [[processEnv(), { STRIPE_KEY: string() }]],
  references: {
    handlers: {
      'aws-sm': createAwsSecretHandler({ region: 'us-east-1' }),
    },
  },
  options: strictReferenceResolveOptions({
    sensitiveKeys: ['STRIPE_KEY'],
    secretSources: ['aws-secrets'],
  }),
});

What this enforces:

  • allowDotenvInProduction: false (secure default)
  • enforceAllowedSources for sensitive keys (prevents accidental fallback)
  • enableAudit: true for source reconstruction via getAuditLog()

If you only want policies (not full resolve options), use:

import { strictReferencePolicies } from 'node-env-resolver';

const policies = strictReferencePolicies({
  sensitiveKeys: ['STRIPE_KEY', 'DATABASE_URL'],
  secretSources: ['aws-secrets'],
});

Note on process.env mutation:

  • resolve() / resolveAsync() keep resolved values out of process.env by default (preventProcessEnvWrite: true).
  • Resolved values live in the returned typed config object unless your app explicitly re-exports or assigns them.
  • If you explicitly set preventProcessEnvWrite: false, primitive resolved values are written to process.env.

Audit Logging

Track environment variable resolution for security and compliance monitoring.

Important: Audit is Disabled by Default in Development

Audit logging is only enabled automatically in production (NODE_ENV === 'production') for performance reasons. In development, you must explicitly enable it.

Enable Audit Logging

import { resolve, getAuditLog, clearAuditLog } from 'node-env-resolver';
import { postgres, string } from 'node-env-resolver/validators';

// Option 1: Explicitly enable (works in any environment)
const config = resolve(
  {
    DATABASE_URL: postgres(),
    API_KEY: string(),
  },
  {
    enableAudit: true, // ← Enable audit logging
  },
);

console.log(getAuditLog());
// [
//   { type: 'env_loaded', key: 'DATABASE_URL', source: 'process.env', timestamp: ... },
//   { type: 'env_loaded', key: 'API_KEY', source: 'process.env', timestamp: ... },
//   { type: 'validation_success', metadata: { variableCount: 2 }, timestamp: ... }
// ]

// Clear the log
clearAuditLog();
import { resolve, getAuditLog } from 'node-env-resolver';
import { postgres } from 'node-env-resolver/validators';

// Option 2: Automatic in production
process.env.NODE_ENV = 'production';

const config = resolve({
  DATABASE_URL: postgres(),
});

// Audit automatically enabled in production
console.log(getAuditLog());

Per-Config Audit Tracking

When you have multiple resolve() calls, you can now get audit logs specific to each config object:

import { resolve, getAuditLog } from 'node-env-resolver';
import { string, postgres } from 'node-env-resolver/validators';

const appConfig = resolve(
  {
    PORT: 3000,
    API_KEY: string(),
  },
  { enableAudit: true },
);

const dbConfig = resolve(
  {
    DATABASE_URL: postgres(),
    DB_POOL_SIZE: 10,
  },
  { enableAudit: true },
);

// Get audit logs for specific config (NEW!)
const appAudit = getAuditLog(appConfig); // Only PORT and API_KEY events
const dbAudit = getAuditLog(dbConfig); // Only DATABASE_URL and DB_POOL_SIZE events

// Still works: get ALL audit events (backward compatible)
const allAudit = getAuditLog(); // All events from both configs

console.log('App config audit:', appAudit);
// [
//   { type: 'env_loaded', key: 'PORT', source: 'process.env', ... },
//   { type: 'env_loaded', key: 'API_KEY', source: 'process.env', ... },
//   { type: 'validation_success', ... }
// ]

console.log('DB config audit:', dbAudit);
// [
//   { type: 'env_loaded', key: 'DATABASE_URL', source: 'process.env', ... },
//   { type: 'env_loaded', key: 'DB_POOL_SIZE', source: 'process.env', ... },
//   { type: 'validation_success', ... }
// ]

Use cases:

  • Multi-tenant applications with separate configs per tenant
  • Microservices with different config sources
  • Testing/debugging specific configuration loads
  • Isolating audit trails in complex applications

Audit Event Types

| Event Type | When It Occurs | | -------------------- | -------------------------------------------------------- | | env_loaded | Environment variable successfully loaded from a resolver | | validation_success | All variables validated successfully | | validation_failure | Variable validation failed | | policy_violation | Security policy check failed | | resolver_error | Resolver failed to load data |

Monitoring Cache Performance

Audit logs include cache metadata to help monitor performance:

import { resolveAsync, getAuditLog } from 'node-env-resolver';
import { cached, TTL } from 'node-env-resolver/utils';
import { postgres } from 'node-env-resolver/validators';