Encryption Key Rotation
Align encrypts OAuth credentials at rest with AES-256-GCM using a master key you provide. This page documents the supported procedure for rotating that key with zero downtime - a named control for SOC 2 key lifecycle management.
What the master key protects
ALIGN_MASTER_ENCRYPTION_KEY is used in two encryption domains:
- OAuth tokens - the
oauth_tokens.encrypted_valuecolumn (every connector's stored OAuth access/refresh tokens) is encrypted with AES-256-GCM by the gateway, in a versioned envelope ([version][iv][authTag][ciphertext]). This domain supports graceful multi-key rotation (below). - Tenant LLM API keys - the
tenant_llm_config.*_api_key_encryptedcolumns are encrypted with PostgreSQLpgcrypto(pgp_sym_encrypt), and the sameALIGN_MASTER_ENCRYPTION_KEYvalue is supplied as theapp.encryption_keysession setting (gatewaytenantConfigRoutes.ts, braindb.py).
The OAuth-token path can try several keys on decrypt, so it rotates with zero
downtime. The pgcrypto LLM-key columns cannot - pgp_sym_decrypt needs the
exact key that encrypted the value. Rotating ALIGN_MASTER_ENCRYPTION_KEY while
tenants have stored LLM keys therefore requires re-encrypting those columns in
the same change (see Rotating the LLM-key columns).
If no tenant has stored an LLM key, this section is moot.
Keys and environment variables
| Variable | Role |
|---|---|
ALIGN_MASTER_ENCRYPTION_KEY | Primary key. All new tokens are encrypted with this. 32 bytes, base64 or hex. |
ALIGN_PREVIOUS_ENCRYPTION_KEYS | Optional, comma-separated list of retired keys retained for decryption only during a rotation. |
Generate a new key:
openssl rand -base64 32
On decryption, Align tries the primary key first, then each retired key in turn, so tokens written with an old key keep working while you migrate. A malformed key in either variable fails loudly at startup rather than on the first token read.
Zero-downtime rotation procedure
┌─ new primary ─┐ ┌─ re-encrypt ─┐ ┌─ drop old ─┐
before │ MASTER = old │ ───► │ MASTER = new │ ───► │ MASTER = new │
MASTER=old│ PREVIOUS = — │ │ PREVIOUS=old │ │ PREVIOUS = — │
└───────────────┘ └──────────────┘ └────────────┘
1. Promote a new primary key
Set the new key as primary and keep the old key for decryption, then redeploy:
ALIGN_MASTER_ENCRYPTION_KEY=<new-key>
ALIGN_PREVIOUS_ENCRYPTION_KEYS=<old-key>
After this deploy, all newly written tokens use the new key; existing tokens still decrypt via the previous key. The system is fully functional in this state - you can pause here indefinitely.
2. Re-encrypt existing tokens
Run the rotation job with both keys in the environment. It decrypts every stored token (with whichever key works) and re-writes it under the new primary key:
# Dry run first - reports what would change, writes nothing
DATABASE_URL=<admin-url> \
ALIGN_MASTER_ENCRYPTION_KEY=<new-key> \
ALIGN_PREVIOUS_ENCRYPTION_KEYS=<old-key> \
pnpm --filter @align/gateway rotate:keys --dry-run
# Real run
DATABASE_URL=<admin-url> \
ALIGN_MASTER_ENCRYPTION_KEY=<new-key> \
ALIGN_PREVIOUS_ENCRYPTION_KEYS=<old-key> \
pnpm --filter @align/gateway rotate:keys
Use the admin / migration DATABASE_URL (the table owner), not the
restricted align_app role - the job must read every tenant's rows, which
Row-Level Security would otherwise filter out. The job is
idempotent: tokens already on the primary key are skipped, so it is safe to
re-run.
The summary line reports scanned, migrated, already-current, and failed.
A non-zero failed count means some token could not be decrypted with any
configured key - usually the old key was removed from
ALIGN_PREVIOUS_ENCRYPTION_KEYS too early. Restore it and re-run before step 3.
3. Retire the old key
Once the job reports failed=0 and no tokens remain on the old key, remove it:
ALIGN_MASTER_ENCRYPTION_KEY=<new-key>
# ALIGN_PREVIOUS_ENCRYPTION_KEYS unset
Redeploy. The old key is now fully retired and can be destroyed in your secret store.
Kubernetes / Helm
The keys are delivered as Kubernetes secrets (see Secrets Management).
During a rotation, add ALIGN_PREVIOUS_ENCRYPTION_KEYS to the gateway secret
alongside the new ALIGN_MASTER_ENCRYPTION_KEY, run the job (e.g. as a one-off
kubectl exec into the gateway pod or a Job), then remove the previous-keys
entry and re-seal.
Rotating the LLM-key columns (pgcrypto)
Only needed if tenants have stored their own LLM API keys
(tenant_llm_config.*_api_key_encrypted). Because pgcrypto has no key
fallback, re-encrypt these columns in a single statement that decrypts with the
old key and re-encrypts with the new key, then deploy the new key. Run
this during the rotation window (step 1 above), as the DB admin/owner role:
-- Re-encrypt every tenant LLM key from <old> to <new>, in one transaction.
UPDATE tenant_llm_config SET
openai_api_key_encrypted = CASE WHEN openai_api_key_encrypted IS NOT NULL
THEN pgp_sym_encrypt(pgp_sym_decrypt(openai_api_key_encrypted, '<old-key>'), '<new-key>') END,
anthropic_api_key_encrypted = CASE WHEN anthropic_api_key_encrypted IS NOT NULL
THEN pgp_sym_encrypt(pgp_sym_decrypt(anthropic_api_key_encrypted, '<old-key>'), '<new-key>') END,
custom_api_key_encrypted = CASE WHEN custom_api_key_encrypted IS NOT NULL
THEN pgp_sym_encrypt(pgp_sym_decrypt(custom_api_key_encrypted, '<old-key>'), '<new-key>') END;
Sequence to avoid a decryption gap: run this UPDATE first, then immediately
deploy the new ALIGN_MASTER_ENCRYPTION_KEY so the app's app.encryption_key
matches the re-encrypted data. Until that deploy lands, the gateway/brain still
present the old key, so do the two steps back-to-back (or in a short maintenance
window). The OAuth-token re-encryption (script above) can run independently on
its own schedule.
Recommended cadence
- Rotate at least annually, and immediately on any suspected key compromise or operator offboarding.
- Store keys in a managed secret store (AWS Secrets Manager, Vault). A future
KeyProvidercan source the key from KMS/Vault directly; the interface is already in place (services/gateway/src/auth/tokenEncryption.ts). - Per-tenant data keys are a possible future enhancement and are not required for this rotation flow.