Base 3:16
Operations

Rotating the encryption key

Replacing the key that protects stored credentials, without losing any of them.

Four things are stored encrypted rather than hashed, because Base316 has to use them rather than merely check them:

RuntimeOAuthProvider.clientSecretPresented to Google or Apple
SsoConnection.clientSecretPresented to a customer's identity provider
OidcSigningKey.privateKeySigns the tokens Base316 issues
StripeWebhookEndpoint.secretVerifies events Stripe sends

All four use AES-256-GCM under BASE316_ENCRYPTION_KEY.

The keyring

A ciphertext records which key made it:

v2.<keyId>.<iv>.<tag>.<ciphertext>

That is what makes rotation possible: several keys can be held at once, new values are written under the primary, and old values still decrypt under the key that made them.

VariableFor
BASE316_ENCRYPTION_KEYOne key, which becomes k1. Fine until you need to rotate
BASE316_ENCRYPTION_KEYSid:base64 pairs, comma separated, primary first
# Before a rotation
BASE316_ENCRYPTION_KEY=<the current key>

# During one — new key first, old key still present
BASE316_ENCRYPTION_KEYS=k2:<new key>,k1:<old key>

# After
BASE316_ENCRYPTION_KEYS=k2:<new key>

Rotating

The order matters. Dropping the old key before step 3 finishes makes every value still under it permanently unreadable — those credentials would have to be re-entered by hand.

1. Generate a key.

openssl rand -base64 32

2. Add it as the primary, keeping the old one.

BASE316_ENCRYPTION_KEYS=k2:<new>,k1:<old>

Deploy. Nothing breaks: existing values still decrypt under k1, and anything written from now on uses k2.

3. Check, then move everything.

pnpm --filter @repo/scripts rotate-keys:check   # reports, writes nothing
pnpm --filter @repo/scripts rotate-keys
Rotation complete.

  Primary key    k2
  Moved              4
  Already there        0
  Still pending        0

Everything is on the primary key. The old key can now be dropped.

The script is idempotent — a partial run is resumed by running it again — and exits non-zero while anything is still pending, so a deploy pipeline can gate the next step on it.

4. Drop the old key.

BASE316_ENCRYPTION_KEYS=k2:<new>

Only once Still pending is 0.

If something cannot be decrypted

Could not be decrypted. Each of these has to be re-entered by hand:
  StripeWebhookEndpoint.secret  swe_abc123  (written under k1)

This means a value was encrypted under a key the process no longer holds — almost always an old key dropped too early. The rotation reports it rather than skipping it quietly, because silence here becomes a broken integration nobody can explain months later.

There is no recovery from the ciphertext. Re-enter the credential: reconnect the OAuth provider, re-add the SSO connection, or let Base316 mint a new signing key.

Notes

  • A rotation walks every row, not one environment. Keys are process-wide, so a partial rotation would leave the rest unreadable the moment the old key was dropped.
  • Values written before key ids existed (v1.…) still read, and are moved onto the primary key by the same run.
  • Decryption fails closed. A wrong key, an unknown key id, or a tampered ciphertext all return nothing — never a plausible-looking wrong value.

On this page