Workglow provides a layered credential management system for storing and resolving sensitive values -- API keys, tokens, and passwords -- required by AI providers and other external services. The system is designed around three principles: pluggable backends (swap between in-memory, environment variable, or encrypted storage without changing task code), automatic resolution (credentials are looked up transparently during the input resolution phase before a task executes), and worker isolation (credentials are resolved on the main thread and passed as plain values into worker contexts, so workers never need access to credential stores or key material).
The credential subsystem spans several packages:
| File | Purpose |
|---|---|
packages/util/src/credentials/ICredentialStore.ts |
ICredentialStore interface and CREDENTIAL_STORE service token |
packages/util/src/credentials/InMemoryCredentialStore.ts |
Non-persistent in-memory store for development |
packages/util/src/credentials/EnvCredentialStore.ts |
Read credentials from environment variables |
packages/util/src/credentials/ChainedCredentialStore.ts |
Layered resolution across multiple stores |
packages/util/src/credentials/CredentialStoreRegistry.ts |
Global store registration, resolveCredential(), and input resolver registration |
packages/util/src/credentials/OtpPassphraseCache.ts |
XOR-masked passphrase cache with TTL |
packages/util/src/credentials/CredentialProviderOptions.ts |
Provider enum values for metadata |
packages/util/src/credentials/CredentialPutInputSchema.ts |
JSON Schema for credential storage forms |
packages/util/src/crypto/WebCrypto.ts |
AES-256-GCM encryption/decryption via Web Crypto API |
packages/storage/src/credentials/EncryptedKvCredentialStore.ts |
Encrypted-at-rest store backed by any IKvStorage |
packages/storage/src/credentials/LazyEncryptedCredentialStore.ts |
Lock/unlock wrapper for EncryptedKvCredentialStore |
When an AI provider needs an API key, the resolution follows a strict three-tier
fallback chain: credential_key (looked up from the credential store), then
api_key (inline literal), then environment variable (provider-specific).
Every provider's getClient() function implements this pattern identically:
const apiKey =
config?.credential_key || // 1. Resolved from credential store
config?.api_key || // 2. Inline API key in provider_config
process.env?.ANTHROPIC_API_KEY; // 3. Environment variable fallbackThe credential_key field in provider_config is annotated with
format: "credential" in the model schema. During the resolveSchemaInputs()
phase of task execution, the TaskRunner walks the input schema, finds properties
with this format annotation, and invokes the registered "credential" input
resolver. That resolver calls resolveCredential(id, registry) which queries the
credential store for the key and returns the actual secret value. By the time the
provider's getClient() function runs, credential_key already contains the
resolved API key string -- not the store lookup key.
The full resolution sequence during task execution:
1. User configures model:
{ provider_config: { credential_key: "my-anthropic-key", model_name: "claude-sonnet-4-20250514" } }
2. TaskRunner calls resolveSchemaInputs() before execute():
- Walks input schema, finds credential_key with format: "credential"
- Calls registered "credential" resolver
- Resolver calls resolveCredential("my-anthropic-key", registry)
- Credential store returns "sk-ant-..." (the actual API key)
- credential_key is now "sk-ant-..."
3. Provider getClient() receives the resolved config:
config.credential_key === "sk-ant-..." // Already the real API key
If the credential store does not contain the requested key, the resolver returns the
original string unchanged, allowing getClient() to fall through to the api_key
or environment variable checks.
All credential stores implement the ICredentialStore interface, which provides a
minimal key-value API for secret management:
interface ICredentialStore {
get(key: string): Promise<string | undefined>;
put(key: string, value: string, options?: CredentialPutOptions): Promise<void>;
delete(key: string): Promise<boolean>;
has(key: string): Promise<boolean>;
keys(): Promise<readonly string[]>;
deleteAll(): Promise<void>;
}The keys() method returns only key names, never values. The get() method returns
undefined both when a key does not exist and when it has expired. Implementations
must not log or expose credential values in error messages.
Each credential can carry optional metadata via CredentialPutOptions:
interface CredentialPutOptions {
readonly label?: string; // Human-readable label
readonly provider?: string; // Associated provider name (e.g., "anthropic")
readonly expiresAt?: Date; // Expiration date (undefined = never)
}The global credential store is registered through the dependency injection system
under the CREDENTIAL_STORE service token:
import { CREDENTIAL_STORE, setGlobalCredentialStore } from "@workglow/util";
setGlobalCredentialStore(myStore);
// Or register directly in a ServiceRegistry
registry.registerInstance(CREDENTIAL_STORE, myStore);A plain Map-backed store for development and testing. Credentials are stored in
memory and lost when the process exits. It respects expiration dates -- expired
entries are cleaned up lazily on get(), has(), and keys() calls.
import { InMemoryCredentialStore } from "@workglow/util";
const store = new InMemoryCredentialStore();
await store.put("openai-api-key", "sk-...", { provider: "openai" });
const key = await store.get("openai-api-key"); // "sk-..."Reads credentials from environment variables using either explicit key-to-variable mappings or a prefix-based naming convention. When no explicit mapping exists for a key, the store converts the key to uppercase, replaces hyphens with underscores, and optionally prepends a prefix.
import { EnvCredentialStore } from "@workglow/util";
// Explicit mapping
const store = new EnvCredentialStore({
"anthropic-api-key": "ANTHROPIC_API_KEY",
"openai-api-key": "OPENAI_API_KEY",
});
const key = await store.get("anthropic-api-key"); // reads process.env.ANTHROPIC_API_KEY
// Prefix convention
const prefixed = new EnvCredentialStore({}, "WORKGLOW");
// "my-api-key" resolves to process.env.WORKGLOW_MY_API_KEYThe put() method sets the environment variable for the current process lifetime.
The environment variable check uses typeof process !== "undefined" to avoid
crashing in browser environments where process is not available.
Encrypts credential values with AES-256-GCM before persisting them to any
IKvStorage backend (SQLite, PostgreSQL, IndexedDB, in-memory). This is the
recommended store for production deployments. Encryption uses the Web Crypto API,
available in Node 20+, Bun, and all modern browsers.
import { EncryptedKvCredentialStore } from "@workglow/storage";
import { SqliteKvStorage } from "@workglow/storage";
const kv = new SqliteKvStorage(":memory:");
const store = new EncryptedKvCredentialStore(kv, "my-encryption-passphrase");
await store.put("openai-api-key", "sk-...", { provider: "openai" });
const key = await store.get("openai-api-key"); // "sk-..."Each stored credential record contains: the encrypted ciphertext (base64), the initialization vector (base64), and plaintext metadata (label, provider, timestamps, expiration). The passphrase is never stored -- it must be provided at construction time.
A lock/unlock wrapper around EncryptedKvCredentialStore. The store starts in a
locked state where reads silently return undefined and writes throw. This is useful
in applications where the user provides a passphrase after startup:
import { LazyEncryptedCredentialStore } from "@workglow/storage";
const lazy = new LazyEncryptedCredentialStore(kvStorage);
await lazy.get("key"); // undefined (locked)
lazy.unlock("user-passphrase");
await lazy.get("key"); // decrypted value
lazy.lock(); // discards inner store and derived key cacheCombines multiple stores into a single ICredentialStore with cascading lookup.
Reads try each store in order, returning the first match. Writes always go to the
first (primary) store. This enables layered resolution patterns:
import {
ChainedCredentialStore,
InMemoryCredentialStore,
EnvCredentialStore,
} from "@workglow/util";
import { EncryptedKvCredentialStore } from "@workglow/storage";
const store = new ChainedCredentialStore([
new InMemoryCredentialStore(), // Runtime overrides
new EncryptedKvCredentialStore(kv, passphrase), // Persistent encrypted
new EnvCredentialStore({ "openai": "OPENAI_API_KEY" }), // Environment fallback
]);Every model configuration includes a provider_config object. The base
ModelConfigSchema declares the common credential_key field, while each provider
extends it with provider-specific fields:
| Field | Type | Description |
|---|---|---|
credential_key |
string |
Key to look up in the credential store. Annotated with format: "credential" for automatic resolution. |
api_key |
string |
Inline API key (fallback when credential_key is absent). Not part of the persisted schema -- used only in getClient() resolution. |
base_url |
string |
Override the provider's default API endpoint. Useful for proxies, Azure OpenAI, or self-hosted instances. |
model_name |
string |
The provider-specific model identifier (e.g., "claude-sonnet-4-20250514", "gpt-4o"). |
max_tokens |
integer |
Default max tokens for responses (Anthropic-specific). |
organization |
string |
Organization ID (OpenAI-specific). |
The credential_key field is marked with "x-ui-hidden": true in provider schemas,
keeping it out of end-user form UIs while still participating in the automatic
resolution system.
Each provider's getClient() function follows the same resolution pattern but checks
different environment variables as the final fallback:
| Provider | Environment Variable(s) | Client Constructor |
|---|---|---|
| Anthropic | ANTHROPIC_API_KEY |
new Anthropic({ apiKey, baseURL }) |
| OpenAI | OPENAI_API_KEY |
new OpenAI({ apiKey, baseURL, organization }) |
| Google Gemini | GOOGLE_API_KEY, GEMINI_API_KEY |
new GoogleGenerativeAI(apiKey) |
| Hugging Face | HF_TOKEN |
new InferenceClient(apiKey) |
| Ollama | (none -- local service) | new Ollama({ host }) |
Ollama is the exception: it does not require an API key because it runs as a local
service. Its getClient() only reads base_url from provider_config, defaulting
to http://localhost:11434.
Providers that support browser usage (Anthropic, OpenAI) pass
dangerouslyAllowBrowser: true when they detect a browser or web worker context via
typeof globalThis.document !== "undefined" || "WorkerGlobalScope" in globalThis.
Workers run in an isolated runtime with a separate globalServiceRegistry. They do
not have access to the main thread's credential store, model repository, or other
registered services. This is a deliberate security boundary: credentials are resolved
on the main thread and passed into workers as plain string values through the
serialized job input.
The flow works as follows:
- The
TaskRunneron the main thread callsresolveSchemaInputs(), which resolvescredential_keyvalues from the credential store into actual API key strings. - The resolved
ModelConfig(now containing the real API key incredential_key) is passed as part of theAiJobInputto the worker via structured cloning. - Inside the worker, the provider's
getClient()readsconfig.credential_keydirectly -- it receives the already-resolved API key, not a store lookup key.
This design means:
- Workers never access credential stores. They receive only the specific credential values they need for a single execution.
- Passphrase material stays on the main thread. The encryption passphrase for
EncryptedKvCredentialStorenever crosses the worker boundary. - The blast radius is minimized. A compromised worker can only see the credentials passed to it for the current job, not the entire credential store.
EncryptedKvCredentialStore encrypts each credential value with AES-256-GCM using
the Web Crypto API. Key derivation uses PBKDF2 with 600,000 iterations and SHA-256,
following current OWASP recommendations. Each encryption operation generates a random
16-byte salt and 12-byte IV. The salt is prepended to the ciphertext so that
decryption can reconstruct the derived key. Derived CryptoKey objects are cached
per-salt in memory to avoid redundant PBKDF2 work.
Metadata (label, provider, timestamps) is stored in plaintext alongside the encrypted value, since it contains no secret material.
For applications that prompt the user for a passphrase, OtpPassphraseCache provides
a time-limited in-memory cache that avoids storing the plaintext passphrase directly.
It XOR-masks the passphrase with a random one-time pad and stores only the masked
value and the pad as Uint8Array instances. The plaintext is reconstructed on each
retrieve() call by XOR-ing the two arrays back together.
import { OtpPassphraseCache } from "@workglow/util";
const cache = new OtpPassphraseCache({
hardTtlMs: 6 * 60 * 60 * 1000, // 6 hours absolute expiry
idleTtlMs: 30 * 60 * 1000, // 30 minutes idle expiry
onExpiry: () => lazyStore.lock(),
});
cache.store("user-entered-passphrase");
const passphrase = cache.retrieve(); // Reconstructs plaintext
cache.clear(); // Zeroes both buffersThe cache supports two TTL modes: a hard TTL (unconditional expiry) and an idle TTL
(resets on each retrieve() call). When either fires, both buffers are zeroed and
the onExpiry callback is invoked -- typically used to lock a
LazyEncryptedCredentialStore.
- Never log credential values. The
ICredentialStorecontract explicitly prohibits implementations from including secret values in error messages. keys()never returns values. Only key names are listed, never the secrets themselves.- Scoped resolution.
resolveCredential()accepts an optionalServiceRegistryparameter, allowing different parts of an application to use different credential stores. A registry-scoped store takes precedence over the global store. - Expired credentials are garbage-collected. All store implementations check
expiresAton reads and silently remove expired entries.
EnvCredentialStorereadsprocess.envdirectly.EncryptedKvCredentialStoretypically usesSqliteKvStorageorPostgresKvStorageas its backend.OtpPassphraseCachetimers are.unref()-ed so they do not keep the process alive.
EnvCredentialStorereturnsundefinedfor all lookups (noprocess.env). Browser applications should useInMemoryCredentialStoreorEncryptedKvCredentialStorebacked byIndexedDbKvStorage.- The Web Crypto API (
crypto.subtle) is available natively -- no polyfills needed. - Provider clients (Anthropic, OpenAI) set
dangerouslyAllowBrowser: truewhen they detect a browser or web worker context (seepackages/ai-provider/src/provider-anthropic/common/Anthropic_Client.ts:48andpackages/ai-provider/src/provider-openai/common/OpenAI_Client.ts:49). This is not automatically safe: any API key loaded into a browser context — even one decrypted fromEncryptedKvCredentialStoreat runtime — is reachable from the page and should be treated as exposed to the end user. Use browser-side provider calls only with short-lived, user-scoped keys, a proxy that injects credentials server-side, or local providers (Ollama, Transformers.js, MediaPipe) that do not require a secret at all. - Web Workers receive credentials via structured cloning, same as Node/Bun workers.
import { ChainedCredentialStore, InMemoryCredentialStore, EnvCredentialStore,
setGlobalCredentialStore } from "@workglow/util";
import { EncryptedKvCredentialStore, SqliteKvStorage } from "@workglow/storage";
// 1. Create the encrypted backend
const kv = new SqliteKvStorage("credentials.db");
const passphrase = process.env.CREDENTIAL_PASSPHRASE;
if (!passphrase) {
throw new Error("CREDENTIAL_PASSPHRASE environment variable is required");
}
const encrypted = new EncryptedKvCredentialStore(kv, passphrase);
// 2. Chain with environment fallback
const store = new ChainedCredentialStore([
encrypted,
new EnvCredentialStore({
"anthropic-api-key": "ANTHROPIC_API_KEY",
"openai-api-key": "OPENAI_API_KEY",
"google-api-key": "GOOGLE_API_KEY",
"hf-token": "HF_TOKEN",
}),
]);
// 3. Register globally
setGlobalCredentialStore(store);import { ModelRepository } from "@workglow/ai";
const repo = new ModelRepository(storage);
await repo.upsert({
model_id: "claude-sonnet",
title: "Claude Sonnet",
description: "Anthropic Claude Sonnet model",
provider: "anthropic",
tasks: ["TextGenerationTask", "TextSummaryTask"],
provider_config: {
model_name: "claude-sonnet-4-20250514",
credential_key: "anthropic-api-key", // Resolved from credential store at runtime
max_tokens: 4096,
},
metadata: {},
});import { ServiceRegistry, CREDENTIAL_STORE, InMemoryCredentialStore } from "@workglow/util";
// Create a scoped registry with its own credential store
const registry = new ServiceRegistry();
const scopedStore = new InMemoryCredentialStore();
await scopedStore.put("api-key", "sk-scoped-...");
registry.registerInstance(CREDENTIAL_STORE, scopedStore);
// resolveCredential checks the registry first, then falls back to global
import { resolveCredential } from "@workglow/util";
const key = await resolveCredential("api-key", registry); // "sk-scoped-..."| Function | Package | Description |
|---|---|---|
getGlobalCredentialStore() |
@workglow/util |
Returns the current global ICredentialStore instance. |
setGlobalCredentialStore(store) |
@workglow/util |
Replaces the global credential store. |
resolveCredential(key, registry?) |
@workglow/util |
Resolves a credential by key, checking the registry-scoped store first, then the global store. |
| Class | Package | Description |
|---|---|---|
InMemoryCredentialStore |
@workglow/util |
Map-backed store for development. |
EnvCredentialStore |
@workglow/util |
Environment variable-backed store with explicit or prefix-based key mapping. |
ChainedCredentialStore |
@workglow/util |
Cascading lookup across multiple stores; writes to the first. |
EncryptedKvCredentialStore |
@workglow/storage |
AES-256-GCM encrypted store backed by any IKvStorage. |
LazyEncryptedCredentialStore |
@workglow/storage |
Lock/unlock wrapper that defers passphrase until needed. |
| Class / Function | Package | Description |
|---|---|---|
OtpPassphraseCache |
@workglow/util |
XOR-masked passphrase cache with hard and idle TTLs. |
encrypt(plaintext, passphrase, keyCache) |
@workglow/util |
AES-256-GCM encryption returning { encrypted, iv }. |
decrypt(encrypted, iv, passphrase, keyCache) |
@workglow/util |
AES-256-GCM decryption returning plaintext. |
deriveKey(passphrase, salt) |
@workglow/util |
PBKDF2 key derivation (600,000 iterations, SHA-256). |
| Token | Type | Description |
|---|---|---|
CREDENTIAL_STORE |
ServiceToken<ICredentialStore> |
DI token for the credential store. Registered with a default InMemoryCredentialStore factory. |