Skip to content

Secrets, encryption & tokens

Config & secrets covers secrets read at boot. This page answers what happens after: secrets stored in the database, secrets on the way to telemetry, third-party OAuth tokens, and comparing secrets safely.

Impact: HIGH survives a leaked backup, a swapped row and a key rotation

  • Every secret the system must send back to someone is sealed: third-party OAuth tokens, outbound webhook secrets, a customer’s credentials, including values inside jsonb. Secrets we only verify, such as our own API keys, are hashed, never sealed.
  • AAD is always table:column:rowId, a required argument. The row id comes from newId(<Entity>Id) before insert. A sealed value is one text column, keyId.iv.ciphertext.tag (base64url). A failed open dies with "@app/infra/SecretBoxOpenFailed" { keyId, table, column }.
  • Rotation is add, switch, reseal, remove: add k3 to SECRET_KEYS, set SECRET_ACTIVE_KEY_ID=k3, run the one-off reseal command, then remove the old key. Each environment has its own SECRET_KEYS. node:crypto is allowed only in infra/secrets.ts and infra/secret-box.ts.

❌ Incorrect — no AAD and no key id, so a swapped ciphertext still decrypts and old keys live forever:

const encrypted = aesEncrypt(key, token) // the same key for every row
yield* sql`UPDATE connections SET token = ${encrypted} WHERE id = ${id}`

✅ Correct — one box, a keyring, AAD bound to the row:

export type SecretBoxShape = {
readonly seal: (plain: Redacted.Redacted<string>, aad: Aad) => Effect.Effect<Sealed>
readonly open: (sealed: Sealed, aad: Aad) => Effect.Effect<Redacted.Redacted<string>>
readonly needsReseal: (sealed: Sealed) => boolean // keyId !== active
}
export const config = Config.all({
keys: Config.Redacted("SECRET_KEYS"), // "k2:<base64 32 bytes>,k1:<…>"
activeKeyId: Config.String("SECRET_ACTIVE_KEY_ID"),
}).pipe(Config.mapEffect(parseKeyring))

Source: notes/12-security-and-trust/secrets-encryption-and-tokens.md · Decision 1, amended

Open in the repository, on demand; carry Redacted, never string

Section titled “Open in the repository, on demand; carry Redacted, never string”

Impact: HIGH Redacted is the only guard that holds without anyone remembering

  • A Row schema holds Sealed, not the secret. find and list never decrypt. Only the method whose caller needs the plaintext calls open, in the repository, where row.id is known for the AAD.
  • Domain types, service shapes and error fields that carry a secret are Redacted.Redacted<string>. An inbound secret in a request body is Schema.RedactedFromValue(Schema.String) in the contract, then sealed before storage.
  • A response never carries a read-back secret. APIs that accept Redacted (HttpClientRequest.bearerToken) take it as it is, with no unwrap.

❌ Incorrect — plaintext typed as string and unwrapped early:

readonly accessToken: (id: ConnectionId) => Effect.Effect<string>
HttpClientRequest.bearerToken(Redacted.value(token))

✅ Correct — open on demand, pass Redacted through:

accessToken: Effect.fn("OAuthTokensRepo.accessToken")(function* (row: OAuthTokenRow) {
return yield* secretBox.open(row.access, SecretBox.aad("oauth_tokens", "access", row.id))
}), // → Redacted<string>
HttpClientRequest.bearerToken(token) // accepts string | Redacted

Source: notes/12-security-and-trust/secrets-encryption-and-tokens.md · Decision 2, amended

Keep secrets out of URLs, headers in telemetry, and error text

Section titled “Keep secrets out of URLs, headers in telemetry, and error text”

Impact: HIGH every real leak went through a URL or a missing header name

  • One Headers.CurrentRedactedNames list for server and client. A new list replaces the eight defaults, so repeat them. An adapter that sends or receives a secret header adds its name in the same change. Vendors that force a key into the URL go on SECRET_IN_URL_HOSTS with HttpClient.TracerDisabledWhen.
  • A secret never goes in a URL path or query we design: use headers, POST bodies, or a fragment for a browser link. An error wrapping a third-party failure carries fixed text plus non-secret fields (status, upstream request id), never String(error).
  • Logs and spans carry ids (keyId, connectionId), never a secret or a prefix of one. db.query.text stays on: it holds placeholders. Lint rules app/no-redacted-value-leak and app/no-redacted-equality enforce the unwrap side; see linting and formatting.

❌ Incorrect — the upstream message, URL and key included, becomes ours:

Effect.mapError((e) => new VendorApiError({ message: String(e) })) // HttpClientError.message has the URL
Effect.annotateLogs("token", token.slice(0, 12)) // a prefix is still the secret

✅ Correct — one header list, and vendor hosts untraced:

Layer.succeed(Headers.CurrentRedactedNames, [
"authorization", "cookie", "set-cookie", "x-api-key",
"AWSAccessKeyId", "Signature", "sig", "X-Goog-Signature",
"x-hub-signature", "x-hub-signature-256", "svix-signature",
"x-internal-secret",
])
Layer.succeed(HttpClient.TracerDisabledWhen, (req) =>
SECRET_IN_URL_HOSTS.has(new URL(req.url).host))

Source: notes/12-security-and-trust/secrets-encryption-and-tokens.md · Decision 3, amended

Refresh OAuth tokens on demand, one refresh per connection at a time

Section titled “Refresh OAuth tokens on demand, one refresh per connection at a time”

Impact: MEDIUM no broken connections from racing refreshes

  • Refresh 60 seconds before expiry, inside a per-connection PartitionedSemaphore, re-reading the row inside the permit. On invalid_grant, re-read: if updatedAt moved, another replica won and its token is used.
  • Only an OAuth invalid_grant on a 400/401 marks the connection needs_reauth; every other failure is IntegrationUnavailable and leaves the status alone. Persist refreshed tokens with 3 retries. No database transaction spans the HTTP refresh.
  • Connections are a tenant table: accessToken reads CurrentOrg, and repository calls take orgId first. Disconnect revokes upstream with the refresh token, best-effort, then deletes.

❌ Incorrect — a row lock held across the provider call:

BEGIN;
SELECT * FROM oauth_tokens WHERE id = $1 FOR UPDATE; -- held while the provider answers
-- … HTTP refresh …
COMMIT;

✅ Correct — an in-process lock per connection, recover by re-reading:

return yield* lock.withPermit(id)(Effect.gen(function* () {
const row = yield* read(orgId, id) // re-read inside the permit
if (row.status === "needs_reauth") return yield* new IntegrationNeedsReauth({ connectionId: id })
if (!expiresWithin(row, Duration.seconds(60))) return yield* repo.accessToken(row)
return yield* refresh(row).pipe(
Effect.catchTag("@app/integrations/InvalidGrant", () => Effect.gen(function* () {
const again = yield* read(orgId, id)
if (again.updatedAt > row.updatedAt) return yield* repo.accessToken(again)
yield* repo.markNeedsReauth(orgId, id)
return yield* new IntegrationNeedsReauth({ connectionId: id })
})),
)
}))

Source: notes/12-security-and-trust/secrets-encryption-and-tokens.md · Decision 5, amended

Compare secrets through one constant-time helper; fail closed

Section titled “Compare secrets through one constant-time helper; fail closed”

Impact: HIGH no timing leak, no RangeError, no check skipped when unset

  • Every comparison of a secret goes through Secrets.equals or Secrets.equalsBytes. No inline timingSafeEqual, no ===. Hashing both sides makes the lengths equal by construction.
  • A secret used for comparison is a required Config.Redacted, never Config.option. A feature that should be off is switched off by not providing its layer or route.
  • Lookups by hash, such as API keys, need no comparison: the index matches an already-hashed input.

❌ Incorrect — === on a secret, and a check that silently turns off:

const secret = yield* Config.option(Config.Redacted("INTERNAL_SECRET"))
if (Option.isSome(secret) && Redacted.value(secret.value) !== header) { /* reject */ }

✅ Correct — one helper, equal lengths by construction:

apps/server/src/infra/secrets.ts
const sha256 = (s: string) => createHash("sha256").update(s, "utf8").digest()
export const equals = (a: Redacted.Redacted<string>, b: Redacted.Redacted<string>): boolean =>
timingSafeEqual(sha256(Redacted.value(a)), sha256(Redacted.value(b)))
export const equalsBytes = (a: Uint8Array, b: Uint8Array): boolean =>
a.byteLength === b.byteLength && timingSafeEqual(a, b)

Source: notes/12-security-and-trust/secrets-encryption-and-tokens.md · Decision 6, amended

  • API keys (app_ak_ + 32 random bytes, SHA-256 at rest, last4 display, shown once via a separate ApiKeyCreated schema, create-then-revoke rotation, lastUsedAt written at most every 5 minutes) — trigger: the first machine client.
  • Removing an old key from SECRET_KEYS — trigger: reseal reports zero rows left on it.
  • Envelope encryption via a KMS — trigger: a customer or an audit requires key custody outside the process, or bring-your-own-key.
  • Secrets delivered as files — trigger: a platform that mounts secrets as files is chosen.