Skip to content

Secret store

Store credentials that the application must read back through secrets:

import { secrets } from "@warlock.js/core";
// Also available from "@warlock.js/core/secrets".
await secrets.set("vendor-token", "a-credential-from-your-provider");
const token = await secrets.get("vendor-token"); // string | undefined
const configured = await secrets.has("vendor-token"); // boolean
const removed = await secrets.remove("vendor-token"); // boolean

All four methods are asynchronous. Missing values return undefined; removing a missing value returns false. Empty strings are valid secrets and has() returns true for them. Names are nonempty strings of at most 256 UTF-16 code units, case-sensitive, and never used as filesystem paths.

Storage is prepared on first use. There is no preparation command, connector, setup step or restart after setting a credential. Importing the module does not import a native addon or create files.

By default, the namespace combines app.appName (or the project directory name) with a SHA-256 hash of the absolute project root. Separate installations therefore have separate credentials even when they share an app name. Processes in the same installation share persistent credentials.

For deployments whose release directory changes, choose a stable, unique namespace in src/config/secrets.ts. Include the deployment environment when production and staging share an OS account:

src/config/secrets.ts
import type { SecretsConfigurations } from "@warlock.js/core";
export default {
namespace: "sample-app-production",
// directory: "/srv/sample-app/private-secrets", // optional; defaults to storage/secrets
} satisfies SecretsConfigurations;

Changing the namespace or encryption key does not migrate existing credentials. Keep the file root outside public and upload directories, beneath a trusted parent owned by the deployment account. Back up encrypted files together with a separately secured copy of the app encryption key.

Inspect the active store without retrieving credential material:

import { secrets, SecretStore, MemorySecretBackend } from "@warlock.js/core/secrets";
import type { SecretBackendType } from "@warlock.js/core/secrets";
const active: SecretBackendType = await secrets.backend(); // "keychain" | "file" | "memory"
const store = new SecretStore({ namespace: "unit-test", backend: new MemorySecretBackend() });
await store.backend(); // "memory"

Both secrets.backend() and SecretStore.backend() are asynchronous. They use core’s existing selection and the same memoized keychain availability probe as get/set/remove; inspecting an unselected store triggers that lazy selection. Concurrent inspection and credential operations share one probe. The result stays stable for that store, even if a selected keychain later fails; failures do not silently switch it to files. A new facade configuration can select a different store.

secrets.driver takes precedence over SECRETS_DRIVER. With "auto", an available keychain reports "keychain"; a missing peer or failing initial probe reports "file". This reports storage selection, not the source of an individual value: scoped environment-variable lookups do not change it, and there is no "environment" backend. Reporting "file" does not create files, read credentials or validate the encryption key or filesystem; later file operations may still fail.

The configured memory driver, secretsFake and explicit MemorySecretBackend instances report "memory" without a keychain probe. Explicit backends bypass driver configuration and environment lookup. A custom SecretBackend may declare readonly type: SecretBackendType to identify its storage; existing injected backends without this metadata report "memory" as isolated test overrides. Core’s explicit file/keychain backend instances report their respective types.

Reads use:

  1. The OS keychain, if available on first use.
  2. The exact namespace-scoped environment variable.
  3. An encrypted local file.

The first operation shares one lazy keychain availability probe across concurrent calls. If the optional peer cannot load or the initial probe fails, the store uses environment/files. A selected keychain stays selected for that store’s lifetime: if it later locks or becomes inaccessible, operations throw UNAVAILABLE instead of silently writing a divergent file copy. No vendor error text or cause is retained. Keep backend availability consistent between deployments; credentials are not automatically reconciled across independent stores writing different backends.

Install the optional peer when OS storage is wanted:

Terminal window
npm install @napi-rs/keyring

Core uses AsyncEntry(service, username, options), getPassword(), setPassword() and deletePassword(). The service is warlock:<namespace>. On Linux it requires linux.store: "secret-service" to avoid the library’s in-memory keyutils fallback, which does not survive reboot. Other platforms use the library’s native store.

These APIs and Linux persistence behavior were verified against the published 2.1.1 declarations and the official README on 2026-10-19. A missing desktop session, locked keychain, or unavailable native module needs no manual preparation step: an initial failure selects the fallback automatically.

Use secrets.environmentName(name) to obtain the exact variable name for your configured namespace. It is WARLOCK_SECRET_<namespace UTF-8 hex>_<name UTF-8 hex> with uppercase hexadecimal characters, avoiding collisions between punctuation, case and namespace boundaries.

import { secrets } from "@warlock.js/core/secrets";
const variableName = secrets.environmentName("vendor-token");
// Supply that variable through your deployment's environment configuration.

Only the exact scoped variable is read; an unscoped VENDOR_TOKEN is not imported. Environment values, including empty strings, cannot be changed or shadowed through this API: both set() and remove() throw READ_ONLY whenever the variable is present, even if a keychain value currently takes precedence. Change the deployment environment to change it.

File writes require the existing encryption.key configuration: exactly 64 hexadecimal characters (32 bytes), conventionally supplied by APP_ENCRYPTION_KEY. See Encryption. No plaintext fallback or automatically generated key file is created when the key is absent.

The file backend derives a separate 32-byte key per namespace with HKDF-SHA256, using the namespace as salt and warlock:secrets:v1 as the derivation context. It encrypts with AES-256-GCM, a fresh 12-byte IV, and authenticated data binding the namespace and secret name. Core’s general encrypt() helper reads global configuration directly; this backend uses Node crypto so it can derive its key without mutating app encryption settings.

The default root is storage/secrets, with a hashed subdirectory per namespace and a hashed filename per name. Both directories are 0700, files are 0600; existing owned entries have their modes tightened. Symlinks, multiply linked files, non-regular files and entries owned by another user are rejected. Writes use an exclusively created temporary file, sync it, and rename it over the destination. Different keys can be written concurrently; concurrent writes to the same key use the last completed rename. The on-disk payload is a version byte (1), IV, 16-byte authentication tag, then ciphertext.

Successful keychain writes remove an older file copy of the same secret. remove() clears both writable layers so a fallback copy cannot reappear; a failure can leave a partially completed operation, so retry after resolving the error.

Windows file fallback fails with UNSAFE_FILE: Node’s Windows mode handling cannot enforce owner-only ACLs. Use the OS keychain or scoped environment variables there. The file implementation uses Node’s documented crypto APIs and filesystem operations, verified for the Node 22 line.

Catch SecretStoreError and inspect its typed code:

import { secrets, SecretStoreError } from "@warlock.js/core/secrets";
try {
await secrets.set("vendor-token", "replacement-credential");
} catch (error) {
if (error instanceof SecretStoreError && error.code === "READ_ONLY") {
// Tell the operator to update the deployment environment.
} else {
throw error;
}
}
CodeMeaning
INVALID_NAME, INVALID_VALUE, INVALID_NAMESPACEInvalid API input
READ_ONLYA scoped environment variable supplies this secret
KEY_REQUIREDFile encryption key is missing or malformed
UNAVAILABLEBackend, filesystem or optional custom backend failed
UNSAFE_FILEOwnership, entry type or permissions cannot be secured
CORRUPT_FILEUnsupported/truncated payload, wrong key or authentication failure

The store never logs values. It registers names, environment keys and values read or submitted with @warlock.js/logger. Every logger instance then masks registered keys and literal values, including interpolated messages, error messages/stacks/causes and channel error reports. Replaced and removed values remain masked for the process lifetime. Custom redaction censors cannot disable this safety floor.

Masking starts when a name/value is encountered and retains credentials in process memory. It does not cover direct console calls, third-party loggers, earlier messages, encoded/transformed variants or credentials never read by the store. Continue to keep credentials out of logs. See Logger redaction.

The standard test kit installs secretsFake by default. It replaces the secrets facade with isolated namespace-aware memory storage, bypassing keychain, environment and files. Opt out through tests.fakes: { secrets: false } only when deliberately exercising real storage. Merely setting NODE_ENV=test without installing the test kit does not replace storage.

import { secrets } from "@warlock.js/core";
import { secretsFake } from "@warlock.js/core/tests";
secretsFake.clear(); // after setupTest() or explicit secretsFake.install()
await secrets.set("vendor-token", "test-credential");
await secretsFake.assertHas("vendor-token");
await secretsFake.assertStored("vendor-token", "test-credential");
await secrets.remove("vendor-token");
await secretsFake.assertMissing("vendor-token");

Each fake lives in its process; HTTP server and worker memory stores are separate. Fake assertions never include values in their error messages. Use clear() between cases; setup/teardown installs/restores the facade automatically.

For unit tests without framework bootstrap, or multiple explicit app instances, construct a store:

import { MemorySecretBackend, SecretStore } from "@warlock.js/core/secrets";
const memory = new MemorySecretBackend();
const first = new SecretStore({ namespace: "first-app", backend: memory });
const second = new SecretStore({ namespace: "second-app", backend: memory });
await first.set("vendor-token", "test-credential");
await second.has("vendor-token"); // false
memory.clear();

SecretStoreOptions accepts namespace, optional directory, optional encryptionKey (a 64-hex string or lazy callback), and optional backend. A SecretBackend implements async get(namespace, name), set(namespace, name, value) and remove(namespace, name). Explicit backends bypass all default layers; the fake affects only the shared facade. Custom backend errors are replaced with safe typed errors.

Run the built server with SECRETS_DRIVER=memory, or select the driver in src/config/secrets.ts. Explicit config takes precedence over the environment; "auto" (the default) keeps keychain/environment/file behavior. Unknown driver names fail with SecretStoreError.code === "INVALID_DRIVER".

src/config/secrets.ts
import type { SecretsConfigurations } from "@warlock.js/core";
export default {
namespace: "browser-test-server",
driver: "memory",
allowMemoryInProduction: true,
} satisfies SecretsConfigurations;

allowMemoryInProduction: true is required only when the test server runs with NODE_ENV=production. Without it, memory selection fails closed with MEMORY_IN_PRODUCTION; a built bundle does not bypass this check. Set this option explicitly in the test deployment configuration. SecretStore accepts the same driver and allowMemoryInProduction options for standalone stores.

The selected memory driver bypasses keychain, scoped environment secrets and files, and still registers values for log masking. Values are isolated by app namespace, lost on process exit, and never shared across workers or with a separate browser-test process. Seed fixtures through application startup or a test-only endpoint in the server process using the normal secrets.set() API:

import { secrets } from "@warlock.js/core";
await secrets.set("vendor-token", "browser-test-credential");
const token = await secrets.get("vendor-token");

The normal test harness still installs secretsFake; explicit backend instances and that fake continue to bypass configured backend selection.