Skip to content

Redacted Secrets

Some configuration values — API keys, database passwords, tokens — must never appear in logs, error messages, or serialized output. Redacted<A> wraps a sensitive value so that any normal rendering path (string interpolation, JSON.stringify, console.log, inspection) shows <redacted> instead of the real value. The underlying value stays recoverable, but only through an explicit, visible call.

import { Config, Effect, Redacted } from "effect"
const program = Effect.gen(function* () {
// Config.redacted parses the value and wraps it in Redacted<string>
const apiKey = yield* Config.redacted("API_KEY")
// Logging the wrapper is safe — it renders as <redacted>
yield* Effect.log(`Loaded API key: ${apiKey}`)
// Unwrap only at the trusted boundary where the secret is actually used
return yield* callApi(Redacted.value(apiKey))
})
declare const callApi: (key: string) => Effect.Effect<unknown>
// API_KEY=sk-1234567890 node app.js
// Logs: Loaded API key: <redacted>

The key idea: a Redacted is safe to pass around, log, and store. The secret is exposed only at the moment you call Redacted.value, which makes those call sites easy to audit.

Config.redacted(name) is the usual entry point. It reads a string from the provider, validates it, and returns a Redacted<string>:

import { Config, Context, Effect, Layer, Redacted } from "effect"
// A service that needs a secret token to do its job
class GitHubClient extends Context.Service<GitHubClient, {
readonly listRepos: Effect.Effect<ReadonlyArray<string>>
}>()("app/GitHubClient") {
static readonly layer = Layer.effect(
GitHubClient,
Effect.gen(function* () {
// Read the secret once, when the layer is built
const token = yield* Config.redacted("GITHUB_TOKEN")
return {
listRepos: Effect.gen(function* () {
// The unwrapped value never leaves this Effect
const auth = `Bearer ${Redacted.value(token)}`
yield* Effect.log("Fetching repos") // token is not logged
return []
})
}
})
)
}

This is the idiomatic shape: read the secret into a service when its layer is built, keep it as a Redacted inside the service, and unwrap it only where it is sent to the external system.

Outside of config, build a Redacted directly with Redacted.make. An optional label is shown in the rendered placeholder, which helps you tell secrets apart in logs without revealing them.

import { Redacted } from "effect"
const token = Redacted.make("secret-token", { label: "github-token" })
String(token) // "<redacted:github-token>"
JSON.stringify(token) // "\"<redacted:github-token>\""
Redacted.value(token) // "secret-token" (explicit, auditable)

Equality and hashing operate on the underlying value, so two Redacteds wrapping the same secret are equal — without ever exposing it. For custom element types, Redacted.makeEquivalence derives an equivalence from one on the inner value:

import { Equivalence, Redacted } from "effect"
const a = Redacted.make("1234567890")
const b = Redacted.make("1234567890")
const c = Redacted.make("0000000000")
const eq = Redacted.makeEquivalence(Equivalence.strictEqual<string>())
eq(a, b) // true — same secret
eq(a, c) // false — different secret

When a secret is no longer needed, Redacted.wipeUnsafe removes it from the internal registry so future Redacted.value calls on that wrapper fail. This is a best-effort scrub for long-lived processes, not a security guarantee.

import { Redacted } from "effect"
const token = Redacted.make("one-time-token")
Redacted.value(token) // "one-time-token"
Redacted.wipeUnsafe(token)
// Redacted.value(token) now throws: "Unable to get redacted value"