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"

Config.redacted(name) is built on Schema.Redacted(Schema.String) — it parses a string from the active provider and wraps it in a Redacted<string> in one step. Use the schema directly when a secret is a field of a larger decoded structure (a request body, a config object) rather than a standalone config key.

import { Config, ConfigProvider, Effect, Redacted } from "effect"
const program = Effect.gen(function* () {
const token = yield* Config.redacted("API_TOKEN")
return Redacted.value(token) // unwrap at the trusted boundary
})
const provider = ConfigProvider.fromUnknown({ API_TOKEN: "sk-secret" })
program.pipe(Effect.provide(ConfigProvider.layer(provider)))

See Config for the full set of config constructors and Schema for using Schema.Redacted inside larger decoded shapes.

The Redacted module is small — a constructor, an unwrapper, a guard, a wipe, and an equivalence builder — plus the rendering and equality behavior baked into every wrapper. This section enumerates every public export.

Wraps a sensitive value, returning a Redacted<A>. The optional label is shown inside the rendered placeholder so you can tell wrappers apart in logs. The type parameter defaults to string.

import { Redacted } from "effect"
const plain = Redacted.make("sk-1234567890")
String(plain) // => "<redacted>"
const labeled = Redacted.make("sk-1234567890", { label: "api-key" })
String(labeled) // => "<redacted:api-key>"
// Works with any value type, not just strings
const creds = Redacted.make({ user: "admin", pass: "hunter2" })
String(creds) // => "<redacted>"

Unwraps a Redacted, returning the underlying value. This is the only way to read the secret, so call sites are easy to audit. Throws Error("Unable to get redacted value") if the wrapper has been wiped.

import { Redacted } from "effect"
const token = Redacted.make("secret-token")
Redacted.value(token) // => "secret-token"

A type guard that returns true for any Redacted wrapper. When it returns true, TypeScript narrows the value to Redacted<unknown>.

import { Redacted } from "effect"
const secret = Redacted.make("my-secret")
Redacted.isRedacted(secret) // => true
Redacted.isRedacted("plain") // => false
Redacted.isRedacted(null) // => false

Deletes the wrapper’s stored value from the internal redacted registry, so future Redacted.value calls on that wrapper throw. Returns true if a value was present and removed, false otherwise. This does not zero memory and does not affect other references to the original value — it is a best-effort scrub, not a cryptographic guarantee.

import { Redacted } from "effect"
const token = Redacted.make("one-time-token")
Redacted.value(token) // => "one-time-token"
Redacted.wipeUnsafe(token) // => true (value was removed)
Redacted.wipeUnsafe(token) // => false (nothing left to remove)
// Redacted.value(token) now throws: "Unable to get redacted value"

Derives an Equivalence<Redacted<A>> from an Equivalence<A>, comparing the underlying values without exposing them. Useful when an API needs to compare secrets structurally (e.g. a non-string payload).

import { Equivalence, Redacted } from "effect"
const eq = Redacted.makeEquivalence(
Equivalence.mapInput(
Equivalence.String,
(c: { token: string }) => c.token
)
)
const a = Redacted.make({ token: "abc" })
const b = Redacted.make({ token: "abc" })
const c = Redacted.make({ token: "xyz" })
eq(a, b) // => true — same token, never exposed
eq(a, c) // => false

Every wrapper’s toString() and toJSON() return the same <redacted> or <redacted:label> placeholder. This is what makes string interpolation, JSON.stringify, console.log, and Node inspection all safe by default.

import { Redacted } from "effect"
const token = Redacted.make("secret", { label: "session" })
`${token}` // => "<redacted:session>"
JSON.stringify({ token }) // => '{"token":"<redacted:session>"}'

Redacted implements the Equal.Equal interface, so structural equality and hashing operate on the underlying value — two wrappers holding the same secret are equal, without exposing it. The wrapper is also Pipeable.

import { Equal, Redacted } from "effect"
const a = Redacted.make("same")
const b = Redacted.make("same")
const c = Redacted.make("different")
Equal.equals(a, b) // => true
Equal.equals(a, c) // => false

Redacted<A = string> is the wrapper interface (it carries the optional label and extends Equal.Equal and Pipeable). The Redacted namespace holds the type-level helpers:

  • Redacted.Redacted.Value<T> — extracts the underlying value type from a Redacted type.
  • Redacted.Redacted.Variance<A> — the internal covariance marker; you rarely reference it directly.
import { Redacted } from "effect"
type ApiKey = Redacted.Redacted<{ readonly token: string }>
type ApiKeyValue = Redacted.Redacted.Value<ApiKey>
// => { readonly token: string }
const rotate = (value: ApiKeyValue): ApiKeyValue => ({
token: `${value.token}:rotated`
})
rotate({ token: "secret" }) // => { token: "secret:rotated" }