Overview

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:

  1. Encrypt credentials before start() so the event log only stores ciphertext.
  2. Use a module-level credentials provider so steps resolve secrets at runtime instead of receiving them as arguments.
  3. 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:

  1. Credentials provider (module-level factory)
  2. Decrypted credentials (from encrypted workflow arguments)
  3. Environment variables (direct process.env fallback)

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

PatternSafe?Why
Step receives API key as argumentNoInput is logged
Step returns an object containing a tokenNoOutput is logged
Step calls resolveCredentials() internallyYesCredentials stay in memory
Helper that returns credentials is called inside a stepYesReturn value is not the step's return value
Helper that returns credentials is marked "use step"NoStep output is logged