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.
Seal read-back secrets with one SecretBox
Section titled “Seal read-back secrets with one SecretBox”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 fromnewId(<Entity>Id)before insert. A sealed value is one text column,keyId.iv.ciphertext.tag(base64url). A failedopendies with"@app/infra/SecretBoxOpenFailed" { keyId, table, column }. - Rotation is add, switch, reseal, remove: add
k3toSECRET_KEYS, setSECRET_ACTIVE_KEY_ID=k3, run the one-offresealcommand, then remove the old key. Each environment has its ownSECRET_KEYS.node:cryptois allowed only ininfra/secrets.tsandinfra/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 rowyield* 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.findandlistnever decrypt. Only the method whose caller needs the plaintext callsopen, in the repository, whererow.idis 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 isSchema.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 | RedactedSource: 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.CurrentRedactedNameslist 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 onSECRET_IN_URL_HOSTSwithHttpClient.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.textstays on: it holds placeholders. Lint rulesapp/no-redacted-value-leakandapp/no-redacted-equalityenforce 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 URLEffect.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. Oninvalid_grant, re-read: ifupdatedAtmoved, another replica won and its token is used. - Only an OAuth
invalid_granton a 400/401 marks the connectionneeds_reauth; every other failure isIntegrationUnavailableand leaves the status alone. Persist refreshed tokens with 3 retries. No database transaction spans the HTTP refresh. - Connections are a tenant table:
accessTokenreadsCurrentOrg, and repository calls takeorgIdfirst. 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.equalsorSecrets.equalsBytes. No inlinetimingSafeEqual, no===. Hashing both sides makes the lengths equal by construction. - A secret used for comparison is a required
Config.Redacted, neverConfig.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:
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
Deferred
Section titled “Deferred”- API keys (
app_ak_+ 32 random bytes, SHA-256 at rest,last4display, shown once via a separateApiKeyCreatedschema, create-then-revoke rotation,lastUsedAtwritten at most every 5 minutes) — trigger: the first machine client. - Removing an old key from
SECRET_KEYS— trigger:resealreports 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.