Writing

A column called encrypted, with no cipher behind it

5 min read

An audit of this codebase found a column named api_key_encrypted that was a plain text column containing a plaintext Linear API key. Alongside it, webhook_secret was plaintext by design and honest about it. The first is worse than the second: a name that claims a property the data does not have is how everyone downstream stops checking.

Both go through a cipher now. That part took an afternoon. The part that took longer, and is the more useful story, is what it takes to be confident nothing else reads those columns.

The cipher, briefly

AES-256-GCM. Format is base64(iv).base64(ciphertext).base64(authTag).

Two parameter choices are worth stating because both are places people reasonably guess wrong:

The IV is 12 bytes, not 16. GCM is specified around a 96-bit nonce. Feeding it 128 bits is legal and triggers an internal derivation step that buys nothing and quietly diverges from every reference implementation you might later compare against.

GCM rather than CBC, because it is authenticated. decryptSecret throws on tampering instead of returning plausible garbage. That property is what makes it acceptable to keep ciphertext in a column that any service-credentialed query can select: an attacker who can write to the column cannot make it decrypt to something of their choosing, they can only make it fail.

There is one behaviour in the wrapper that is more interesting than the crypto:

if (!isEncrypted(value)) {
  console.warn(`[workspace-secrets] ${label} is not in ciphertext form; refusing to use it.`)
  return null
}

A value written before encryption existed — or written while BRIDGE_ENCRYPTION_KEY was unset — is not silently accepted as plaintext. It is reported as absent, so callers take their configured-but-unusable path.

The tempting alternative is a compatibility shim: if it does not look encrypted, use it as-is. That shim would have made the migration seamless and would have meant the column's guarantee was "encrypted, unless it isn't", which is not a guarantee. Refusing is louder and correct.

The plaintext never becomes a field

The wrapper returns an object describing the workspace, and the decrypted secret is deliberately not on it:

/**
 * Whether a usable secret exists — NOT the secret itself.
 * ... captured in the closure below, so a caller cannot log it, put it in
 * props, or accidentally serialize it. Callers verify; they never hold.
 */
hasWebhookSecret: boolean
verifySignature(rawBody: string, signature: string | null): boolean

The secret lives in a closure behind verifySignature. A caller can ask does this signature match and cannot ask what is the secret.

This matters specifically because of React Server Components. An object returned from a server function and passed as a prop gets serialized into the RSC payload, which ships to the browser as text. Not rendered — present. Any field you attach to a server-side object is one careless prop-drill away from being in the page source, and nobody notices, because nothing displays it.

Making the plaintext unreachable by design removes that whole category. There is no field to accidentally forward.

Why the column grant was not sufficient

The obvious defence is a Postgres column-level grant: revoke select on the ciphertext columns from the role the browser reads through. That was done, and it was not enough — a conclusion this project had to correct in writing.

The grant binds the authenticated role. The portal does not connect as authenticated. It connects as postgres, which holds bypassrls, and it renders server components whose props reach the browser. So the column grant protects a path the application does not use, and leaves the path it does use wide open.

The real control turned out to be much more boring: an explicit select list. Every query names its columns; none of them names a ciphertext column. And because "everyone remembers to write the right select list" is exactly the kind of discipline that decays, there is a CI gate that greps for those column identifiers outside the one allowlisted module and fails the build.

That is the honest shape of this defence. Not a clever database feature — a narrow module, an explicit list, and a script that fails the build when something reaches past it. The gate has caught a real omission since.

What we would tell someone copying this

Three things, in the order they bite.

Name the module, not the rule. "Do not select the ciphertext columns" is a rule nobody can enforce. "Only workspace-secrets.ts may select them, and a script fails the build otherwise" is a boundary with an address. The value of the single-module design is not encapsulation, it is that the allowlist has exactly one entry and a reviewer can hold the whole thing in their head.

Decide what an unencrypted value means before you have one. You will have one — from a migration, from a period when the key was unset, from a fixture. The two options are "treat it as plaintext" and "treat it as absent", and the first one is chosen by default when nobody chooses. It is also the one that quietly voids the guarantee.

Assume anything on a returned object reaches the browser. Not because someone will render it, but because RSC serializes props. The rule that has held up here is that a decrypted secret is never a field — it is captured in a closure behind a verb like verifySignature, so the only thing a caller can do with it is the thing they needed it for.

What this costs

Rotating BRIDGE_ENCRYPTION_KEY makes existing ciphertext undecryptable. There is no key-versioning scheme here and no re-encrypt path, which means rotation is a planned migration rather than an operational action. For a system with a handful of connected workspaces that is an acceptable simplification; at a hundred it would not be, and the fix is a key id prefix on the ciphertext format. Writing that down is cheaper than pretending the current design scales.

The related lesson, which cost us more than the cipher did: the guards that decide who may call are a separate mechanism from the controls that decide what may be read, and getting one right tells you nothing about the other. The guards are here.