Skip to content

Secrets

Your Worker needs a Stripe key. You’d rather it wasn’t in your code, your config, your shell history, or your logs. Here’s how that looks:

test/fixtures/secrets/pay.ts
import { Effect, Redacted } from 'effect';
import { Secret, Worker } from '@forge-effect/infra';
export class StripeKey extends Secret.Make<StripeKey>()('stripe-key') {}
export const fetch = Effect.map(StripeKey, (key) => new Response(`a key of ${Redacted.value(key).length} characters`));
export default Worker.make('pay', { fetch }).bind(StripeKey);

The declaration says the Worker has a secret called stripe-key. Its value goes up separately, once, from your terminal:

Terminal window
printf %s "$VALUE" | pnpm infra secret put test/fixtures/secrets/app.ts stripe-key

There are two kinds of secret here: a Worker’s own, as above, and one in your account’s Secrets Store, which many Workers and apps can share. This page covers both.

Secret.Make is a resource like the others: a class that is the declaration and the client at once. Bind it, and in a handler, yield* StripeKey is the value.

The value is a Redacted<string>, Effect’s wrapper for things that mustn’t leak. Log it, put it in an error, JSON.stringify it: you get <redacted:STRIPE_KEY>. You have to ask for the real thing on purpose, with Redacted.value(key), which is easy to spot in a review.

On Cloudflare it’s the Worker secret STRIPE_KEY, the name in capitals with underscores.

A ship deploys the secret declared, without a value. Cloudflare keeps a Worker’s secret values across versions, so every later ship leaves the value alone. You set it after the Worker’s first ship:

Terminal window
printf %s "$VALUE" | pnpm infra secret put app.ts stripe-key
  • The value comes from standard input, never an argument, so it’s not in your shell history or the process list. At a terminal, with nothing piped in, it asks and doesn’t echo what you type.
  • It’s set on each deployed Worker that binds it. Cloudflare makes a new version with it and deploys that at once.
  • Add --env pr-7 to set a preview’s.
  • A Worker that isn’t deployed yet is refused, with what to do: ship it, then set its secret.

A Worker whose secret has no value fails the handler that reads it, saying how to set it:

the Worker's secret STRIPE_KEY has no value: set it with `infra secret put <app.ts> stripe-key`

Better to hear it before a request does. infra check --account reads your account and lists each declared secret with no value, as secret-missing, with the command that sets it. Before the first ship, it’s only a note (info), since a secret can only be set once its Worker exists. See Checks.

infra dev reads secrets from your environment, by their binding name:

Terminal window
STRIPE_KEY=sk_test_123 pnpm infra dev app.ts

One that isn’t set is said when it starts: ! secret pay: STRIPE_KEY has no value: set it in the environment, by that name.

Secret.memory provides a value:

test/secret.test.ts
test("a secret is a Redacted service: a handler reads it, and printing it doesn't show it", async () => {
const response = await Effect.runPromise(Effect.provide(fetch, Secret.memory(StripeKey, 'sk_test_123')));
assert.equal(await response.text(), 'a key of 11 characters');
const key = await Effect.runPromise(Effect.provide(StripeKey, Secret.memory(StripeKey, 'sk_test_123')));
assert.doesNotMatch(String(key), /sk_test/);
assert.doesNotMatch(JSON.stringify({ key }), /sk_test/);
});

A Worker’s secret is that Worker’s. If five Workers across three apps all need the same key, setting it five times, and rotating it five times, gets old. Cloudflare’s Secrets Store keeps secrets for the whole account, and any Worker can bind one.

test/fixtures/vault/app.ts
import { Effect, Redacted } from 'effect';
import { App, SecretsStore, Worker } from '@forge-effect/infra';
export class Greeting extends SecretsStore.Secret<Greeting>()('greeting-key') {}
const fetch = Effect.gen(function* () {
const value = Redacted.value(yield* (yield* Greeting).get); // read from the store, Redacted
const digest = yield* Effect.promise(() => crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
return new Response([...new Uint8Array(digest)].slice(0, 4).map((b) => b.toString(16).padStart(2, '0')).join(''));
}).pipe(Effect.orDie);
export const reader = Worker.make('reader', { fetch }).bind(Greeting);
export default App.make('vault', { workers: [reader] });

Notice the extra step: the client’s get is an Effect, because Cloudflare reads the value from the store when you ask, and that can fail (StoreSecretError: it isn’t there, or this Worker may not read it).

By default greeting-key is the secret greeting_key (underscores) in your account’s only store. Either can be named:

secrets.ts
/** `STRIPE_LIVE_KEY` in the store called `payments`. */
export class StripeKey extends SecretsStore.Secret<StripeKey>()('stripe-key', {
store: 'payments',
// A name in the store, never a value.
secret: 'STRIPE_LIVE_KEY', // pragma: allowlist secret
}) {}

The same command as a Worker’s secret:

Terminal window
printf %s "$VALUE" | pnpm infra secret put test/fixtures/vault/app.ts greeting-key

It sets the value in the store, not on a Worker. If your account has no store yet, it makes one, called default. If it has several and the secret names none, it stops and lists them.

A ship binds the secret by its store’s id. Before it makes anything, it checks the store is there and the secret has a value; if not, it stops with nothing shipped:

stopped: the store secret greeting_key has no value; nothing was shipped: set each with `infra secret put <app.ts> <name>`, which makes the store if the account has none

The store isn’t the app’s. It’s the account’s, shared by whoever binds it, so no destroy or prune ever deletes it or its secrets.

SecretsStore.memory stands in:

test/store.test.ts
const body = await Effect.runPromise(
Effect.flatMap(Worker.handle(reader, new Request('https://x.dev/')), (r) => Effect.promise(() => r.text())).pipe(Effect.provide(SecretsStore.memory(Greeting, 'hello'))),
);
// The first four bytes of SHA-256("hello").
assert.equal(body, '2cf24dba');
Secret.Make SecretsStore.Secret
Belongs to each Worker that binds it the account
Set with infra secret put, per environment infra secret put, once for everyone
Read as yield* Key yield* (yield* Key).get, which can fail
Missing a value check --account says so the ship stops
infra dev from your environment not read
Deleted by destroy with its Worker never

Reach for a Worker’s own secret first. Use the store when several apps share one value, or when one person rotates a key for everyone.

  • Checked live: a Worker’s secret missing, set, read, and kept by the next ship; infra secret put making a store, setting a store secret, and the deployed Worker reading it.
  • infra dev doesn’t read the Secrets Store, so a store secret is unwired locally: reading it fails, and infra dev says so when it starts. Test those handlers with SecretsStore.memory.
  • Setting a Worker’s secret deploys a new version at once, with no stepped rollout: that’s how Cloudflare applies secrets.