🔐🛡️🔑
If you have been living under a rock, your OAuth refresh tokens cannot sit in Postgres as plain text. The moment your DB leaks — and one day it will — every connected Google, Meta, Stripe and Pipedrive account on your platform is somebody else's. The fix is twelve lines of node:crypto and one extra column. No auth-as-a-service required.
Spoiler: AES-256-GCM is the answer, not AES-256-CBC. GCM is an AEAD mode — it gives you encryption and integrity in one call. CBC needs you to bolt on HMAC-SHA256 separately, and the moment a junior dev forgets the HMAC, your tampering check disappears silently. GCM's 16-byte authentication tag throws on decrypt if even one byte was flipped. Free integrity, zero discipline required.
Here is the encrypt half. The key lives in an env var as 64 hex chars (32 bytes). IV is freshly random per call — 12 bytes is the GCM standard, never reuse one with the same key or you compromise the whole channel. Append the auth tag to the ciphertext; the decrypt side will slice it off.
Plot twist: you need a separate IV column per encrypted field. Storing only one IV in marketing_connections.iv and reusing it across access_token and refresh_token is the exact bug that broke a Heroku addon I will not name in 2023. Two encryptions, two IVs, two columns.
The catch: rotating the master key means re-encrypting every row. Plan for it from day one — add a key_version smallint column to the table now, store the version that encrypted each row, and your future migration becomes a background job instead of a panic. Yes really, do it before you have ten thousand rows, not after.
The verdict: behold, 30 lines of standard library and one extra column will outlast three auth libraries' breaking changes. Built-in crypto does not get deprecated, does not change its API, does not require a paid tier. Use it. Do not @ me about KMS — that is the next post, and it is only worth the complexity once you cross a hundred tenants.