Credentials are stored in *_encrypted columns on the organizations row (or in a binding config as enc:v1: strings). Reads decrypt with a 60-second in-memory cache. Legacy plaintext rows written before the encryption layer are detected on read and re-encrypted in place. A blob that is present but undecryptable reports 'not connected' rather than acting as a different identity.
Also called: API key security · where are my passwords stored · AES encryption · credential vault
- 1The settings route encrypts at the boundary before the update statement runs.
- 2getOrgGhlCredentials / getOrgSmartbuildCredentials / getOrgQbCredentials / getStripeClient decrypt on read and cache for 60 seconds.
- 3A legacy plaintext value triggers a fire-and-forget re-encrypt of that row.
- 4A failed decrypt is logged loudly and the integration reads as disconnected.
- 5Saving new credentials invalidates every relevant cache immediately.
Two hazards are recorded in orgIntegrations.ts. The self-heal exists so the table migrates itself: 'Self-heals the table over time without a one-shot migration: anyone whose creds are read after this ships gets their row encrypted on the next read.' The fail-closed rule exists because the alternative is worse than an error: an undecryptable blob keeps the env fallback suppressed because 'surfacing "not connected" is more correct than silently acting as a different GHL identity.'
- Third-party secrets previously sat in plaintext columns.
- A key rotation without migration could have made the app act as the wrong tenant's identity.
See it on your own jobs
Twenty minutes, your numbers, no slide deck. We’ll build one of your real buildings in front of you and send you the estimate link at the end — yours to keep either way.
or keep browsing features →