Secure Credential Handling
Protect API keys and secrets from appearing in the workflow event log using encryption, credential providers, and careful step design.
This is an advanced guide. It covers security patterns for workflows that handle sensitive credentials. It is not required reading to use workflow, but is strongly recommended for production multi-tenant applications.
Why Credentials Need Special Treatment
Workflow SDK persists every step's input and output to an event log for replay and observability. If you pass an API key as a step argument or return it from a step, the plaintext secret is stored in the event log.
Three complementary patterns keep secrets out of the log:
- Encrypt credentials before
start()so the event log only stores ciphertext. - Use a module-level credentials provider so steps resolve secrets at runtime instead of receiving them as arguments.
- Keep credential-resolving helpers out of steps so their return values are never serialized.
Encrypting Credentials Before start()
When a caller triggers a workflow, any arguments passed to start() are serialized into the event log. If those arguments contain API keys, the keys are stored in plaintext. AES-256-GCM encryption solves this: encrypt on the caller side, decrypt inside a step.
The Encryption Utility
// lib/workflow-crypto.ts
import { gcm } from "@noble/ciphers/aes.js";
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
export interface EncryptedPayload {
v: 1;
alg: "aes-256-gcm";
kid?: string; // optional key ID for rotation
iv: string;
tag: string;
ciphertext: string;
}
export async function encryptForWorkflow<T>(
value: T,
key: Uint8Array | string,
keyId?: string,
): Promise<EncryptedPayload> {
const keyBytes = normalizeKey(key); // validate 32-byte key
const iv = new Uint8Array(IV_LENGTH);
crypto.getRandomValues(iv);
const plaintext = new TextEncoder().encode(JSON.stringify(value));
const encrypted = gcm(keyBytes, iv).encrypt(plaintext);
// GCM appends the auth tag to the ciphertext
const tag = encrypted.slice(encrypted.length - TAG_LENGTH);
const ciphertext = encrypted.slice(0, encrypted.length - TAG_LENGTH);
return {
v: 1,
alg: "aes-256-gcm",
...(keyId !== undefined && { kid: keyId }),
iv: bytesToBase64(iv),
tag: bytesToBase64(tag),
ciphertext: bytesToBase64(ciphertext),
};
}
export async function decryptFromWorkflow<T>(
payload: EncryptedPayload,
key: Uint8Array | string,
): Promise<T> {
const keyBytes = normalizeKey(key);
const iv = base64ToBytes(payload.iv);
const tag = base64ToBytes(payload.tag);
const ciphertext = base64ToBytes(payload.ciphertext);
// Recombine ciphertext + tag for GCM decryption
const combined = new Uint8Array(ciphertext.length + tag.length);
combined.set(ciphertext);
combined.set(tag, ciphertext.length);
const plaintext = gcm(keyBytes, iv).decrypt(combined);
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
}
function normalizeKey(key: Uint8Array | string): Uint8Array {
const bytes = typeof key === "string" ? base64ToBytes(key) : key;
if (bytes.length !== 32) {
throw new Error(`Expected 32-byte key, got ${bytes.length}`);
}
return bytes;
}Encrypting on the Caller Side
// app/api/start-workflow/route.ts
import { start } from "workflow/api";
import { encryptForWorkflow } from "@/lib/workflow-crypto";
import { processDocument } from "@/workflows/process-document";
export async function POST(request: Request) {
const { documentId } = await request.json();
// Encrypt credentials before they enter the event log
const encrypted = await encryptForWorkflow(
{
apiKey: process.env.THIRD_PARTY_API_KEY!,
serviceToken: process.env.SERVICE_TOKEN!,
},
process.env.WORKFLOW_SECRET_KEY!,
);
const run = await start(processDocument, [documentId, encrypted]);
return Response.json({ runId: run.id });
}Decrypting Inside a Step
// workflows/process-document.ts
import { decryptFromWorkflow } from "@/lib/workflow-crypto";
import type { EncryptedPayload } from "@/lib/workflow-crypto";
export async function processDocument(
documentId: string,
credentials: EncryptedPayload,
) {
"use workflow";
const result = await fetchDocument(documentId, credentials);
return result;
}
async function fetchDocument(
documentId: string,
credentials: EncryptedPayload,
) {
"use step";
// Decrypt inside the step — the decrypted values never leave this function
const { apiKey } = await decryptFromWorkflow<{ apiKey: string }>(
credentials,
process.env.WORKFLOW_SECRET_KEY!,
);
const response = await fetch(`https://api.example.com/docs/${documentId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
// Only the document data is returned (and logged), not the API key
return response.json();
}The event log stores the encrypted blob as the step input and the document data as the step output. The plaintext API key exists only in memory during step execution.
Key Rotation
The optional kid (key ID) field supports key rotation. Include a kid when encrypting to identify which key was used. On the decryption side, read payload.kid to look up the correct key:
const encrypted = await encryptForWorkflow(
credentials,
currentKey,
"key-2025-03", // key identifier
);
// On the decryption side
const key = getKeyById(payload.kid); // look up the right key
const decrypted = await decryptFromWorkflow(payload, key);Module-Level Credentials Provider
Encryption works well when credentials originate from the caller. But sometimes the deployment environment itself holds the secrets (e.g., environment variables or a secrets manager), and you want steps to resolve them at runtime without receiving them as arguments.
A credentials provider is a factory function registered at module scope. Steps call it at runtime to get the credentials they need.
Registering a Provider
// lib/credentials-provider.ts
type CredentialsProvider = () =>
| Promise<Record<string, string> | undefined>
| Record<string, string>
| undefined;
let credentialsProvider: CredentialsProvider | undefined;
export function setCredentialsProvider(provider?: CredentialsProvider): void {
credentialsProvider = provider;
}
export async function resolveCredentials(
input?: Record<string, string>,
): Promise<Record<string, string>> {
// 1. Start with provider credentials as the base
const fromProvider = credentialsProvider
? (await credentialsProvider()) ?? {}
: {};
// 2. Merge direct input (overrides provider)
return { ...fromProvider, ...input };
}Setting the Provider at App Startup
// app/instrumentation.ts (Next.js) or server entry point
import { setCredentialsProvider } from "@/lib/credentials-provider";
// Register once at module scope — runs before any workflow step
setCredentialsProvider(() => ({
apiKey: process.env.THIRD_PARTY_API_KEY!,
serviceToken: process.env.SERVICE_TOKEN!,
}));Using the Provider Inside Steps
// workflows/analyze.ts
import { resolveCredentials } from "@/lib/credentials-provider";
export async function analyzeData(datasetId: string) {
"use workflow";
const summary = await runAnalysis(datasetId);
return summary;
}
async function runAnalysis(datasetId: string) {
"use step";
// Resolve credentials at runtime — no secrets in the step's arguments
const { apiKey } = await resolveCredentials();
const response = await fetch(`https://api.example.com/analyze/${datasetId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
return response.json();
}Resolution Order
When both encryption and a provider are in use, a typical resolution order is:
- Credentials provider (module-level factory)
- Decrypted credentials (from encrypted workflow arguments)
- Environment variables (direct
process.envfallback)
Later sources override earlier ones. This lets a library provide sensible defaults while allowing callers to override per-workflow.
Why Some Functions MUST NOT Be Steps
This is the most subtle pattern. Consider a helper function that creates an API client with credentials:
// lib/client-factory.ts
/**
* Resolves client configuration for a workflow.
* This function is NOT a workflow step to avoid exposing
* credentials in step I/O.
*/
export async function createClient(
credentials?: Record<string, string>,
) {
const { apiKey, serviceToken } = await resolveCredentials(credentials);
return {
apiKey,
serviceToken,
baseUrl: "https://api.example.com",
};
}If createClient were marked with "use step", its return value — which contains the plaintext apiKey and serviceToken — would be serialized into the event log for observability. This is a credential leak.
The rule: functions that return or handle credentials should NOT be steps. Instead, call them from inside a step:
// workflows/process.ts
import { createClient } from "@/lib/client-factory";
async function uploadResult(data: Record<string, unknown>) {
"use step";
// createClient runs inside this step — its return value
// stays in memory and is never serialized to the event log
const client = await createClient();
const response = await fetch(`${client.baseUrl}/upload`, {
method: "POST",
headers: {
Authorization: `Bearer ${client.apiKey}`,
"X-Service-Token": client.serviceToken,
},
body: JSON.stringify(data),
});
// Only the upload result is returned (and logged)
return response.json();
}
export async function processAndUpload(inputData: Record<string, unknown>) {
"use workflow";
const result = await uploadResult(inputData);
return result;
}The Key Insight
The event log records:
- Step inputs: the arguments passed to the step function
- Step outputs: the return value of the step function
Anything that happens inside the step but is not an input or output is invisible to the log. By resolving credentials inside the step and only returning non-sensitive results, you keep secrets out of the event log entirely.
What to Watch For
| Pattern | Safe? | Why |
|---|---|---|
| Step receives API key as argument | No | Input is logged |
| Step returns an object containing a token | No | Output is logged |
Step calls resolveCredentials() internally | Yes | Credentials stay in memory |
| Helper that returns credentials is called inside a step | Yes | Return value is not the step's return value |
Helper that returns credentials is marked "use step" | No | Step output is logged |