---
title: Encryption
description: Learn how Workflow SDK encrypts user data end-to-end in the event log.
type: conceptual
summary: Understand how workflow and step data is encrypted at rest.
prerequisites:
  - /docs/how-it-works/event-sourcing
related:
  - /docs/observability
  - /worlds/vercel
---

<Callout>
Workflow SDK automatically encrypts user data in the event log without requiring code changes. To start using workflows, see the [getting started](/docs/getting-started) guide for your framework.
</Callout>

Workflow SDK supports automatic end-to-end encryption of all user data before writing it to the event log. When a `World` implementation provides encryption support, you can pass sensitive data, such as API keys, tokens, or user credentials, as workflow inputs, step arguments, and return values. The storage backend only sees ciphertext.

Encryption support varies by `World` implementation. See the [Worlds](/worlds) page to check which Worlds support this feature. `World` implementations opt into encryption by providing a `getEncryptionKeyForRun()` method. The core runtime uses it automatically when present.

## What is encrypted

All user data flowing through the event log is encrypted:

- **Workflow inputs**: Arguments passed when starting a workflow
- **Workflow return values**: The final output of a workflow
- **Step inputs**: Arguments passed to step functions
- **Step return values**: The result returned by step functions
- **Hook metadata**: Data attached when creating a hook
- **Hook payloads**: Data received by hooks and webhooks
- **Stream data**: Each frame in a `ReadableStream` or `WritableStream`

Metadata such as workflow names, step names, entity IDs, timestamps, and lifecycle states are **not** encrypted. This allows the observability tools to display run structure and timelines without requiring decryption.

## How it works

### Compression

Payloads are compressed before encryption. A format prefix on the stored value records the compression codec, and the inner payload keeps its own serialization format prefix after decompression. Repetitive payloads compress heavily. AI token streams average around 80% smaller, reducing storage and network transfer. Like encryption, compression is automatic and requires no code changes.

The codec is zstd when both the writer and the run's deployment run Node.js 22.15+ (or 23.8+), and gzip otherwise. Runs started on an older Node.js version stay readable after you upgrade the project's Node.js version. To force a codec, set [`WORKFLOW_COMPRESSION_CODEC`](/docs/configuration/runtime-tuning#workflow_compression_codec).

### Key management

Each workflow run is encrypted with its own unique key, provided by the `World` implementation via `getEncryptionKeyForRun()`. How the key is generated and stored is up to the `World`.

For example, the [Vercel World](/worlds/vercel) provides unique keys per run and execution environment, ensuring that a given run can only decrypt data from that run itself.

### Encryption algorithm

Data is encrypted using **AES-256-GCM** via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API):

- A random 12-byte nonce is generated for each encryption operation
- The GCM authentication tag provides integrity verification and detects any ciphertext tampering
- The same plaintext produces different ciphertext each time due to the random nonce

## Decrypting data

When viewing workflow runs through the observability tools, encrypted fields display as locked placeholders until you explicitly choose to decrypt them.

### Permissions

The `World` implementation controls decryption access. On Vercel, decryption follows the same permissions model as project environment variables. If you don't have permission to view environment variable values for a project, you can't decrypt its workflow data. Vercel records each decryption request in your [Vercel audit log](https://vercel.com/docs/audit-log), so your team can see when someone accessed workflow data and who accessed it.

### Web dashboard

Click the **Decrypt** button in the run detail panel to decrypt all data fields. Decryption happens entirely in the browser through the Web Crypto API. The observability server retrieves the encryption key but never sees your plaintext data.

### CLI

Add the `--decrypt` flag to any `inspect` command:

```bash
# Inspect a specific run
npx workflow inspect run <run-id> --decrypt

# Inspect a specific step
npx workflow inspect step <step-id> --run <run-id> --decrypt

# List events for a run
npx workflow inspect events --run <run-id> --decrypt

# Inspect a specific stream
npx workflow inspect stream <stream-id> --run <run-id> --decrypt
```

Without `--decrypt`, encrypted fields display as `🔒 Encrypted` placeholders.

## Custom World implementations

The core runtime encrypts data automatically when the `World` implementation provides a `getEncryptionKeyForRun()` method. The core runtime can call this method in two forms:

{/* @skip-typecheck - interface signature, not runnable code */}
```typescript
getEncryptionKeyForRun?(run: WorkflowRun): Promise<Uint8Array | undefined>;
getEncryptionKeyForRun?(
  runId: string,
  context?: Record<string, unknown>
): Promise<Uint8Array | undefined>;
```

Use `getEncryptionKeyForRun(run)` when the run entity already exists. Use `getEncryptionKeyForRun(runId, context?)` in runtime paths like `start()` where the run has not been created yet but the world may still need context such as `deploymentId`.

To add encryption support to a custom `World`:

1. Implement `getEncryptionKeyForRun()` on your `World` class, handling both call shapes
2. Return the raw 32-byte key as a `Uint8Array`. The core runtime uses it for AES-256-GCM operations
3. Ensure the same key is returned for the same run ID across invocations (for decryption during replay)

```typescript
import type { WorkflowRun, World } from "@workflow/world";

export const getEncryptionKeyForRun: World["getEncryptionKeyForRun"] = async (
  run: WorkflowRun | string,
  context?: Record<string, unknown>
) => {
  const runId = typeof run === "string" ? run : run.runId;
  const deploymentId =
    typeof run === "string"
      ? (context?.deploymentId as string | undefined)
      : run.deploymentId;

  return await lookupRunKey(runId, deploymentId);
};

async function lookupRunKey(
  runId: string,
  deploymentId?: string
): Promise<Uint8Array | undefined> {
  // Look up or derive the encryption key for this run
  // Return undefined to skip encryption
  return new Uint8Array(32);
}
```

The [Vercel World](/worlds/vercel) implementation uses HKDF derivation from a deployment-scoped key, but any consistent key management scheme will work.
