Skip to content

Encryption at Rest ​

By default AuthHero stores most credential fields in plaintext, trusting the database layer. For defense-in-depth against a database-only compromise (a stolen backup, a read-only SQL injection, an accidental dump), AuthHero can transparently encrypt sensitive fields at the application layer using a single symmetric key supplied via the environment.

This protects data only against attackers who can read the database but not the running application — the application must hold the key in memory to operate. It is the same model Auth0/Okta use for storing retrievable secrets like client secrets.

Enabling it ​

Generate a 32-byte key and expose it as ENCRYPTION_KEY:

bash
openssl rand -base64 32

Then wrap your data adapter before passing it to AuthHero:

ts
import { createEncryptedDataAdapter, loadEncryptionKey } from "authhero";

let dataAdapter = createAdapters(db);

if (process.env.ENCRYPTION_KEY) {
  const key = await loadEncryptionKey(process.env.ENCRYPTION_KEY);
  dataAdapter = createEncryptedDataAdapter(dataAdapter, key);
}

When ENCRYPTION_KEY is unset the wrapper is not installed and behavior is unchanged, so enabling encryption is opt-in and zero-config for existing deployments.

What gets encrypted ​

TableField
clientsclient_secret
connectionsoptions.client_secret, options.app_secret, options.twilio_token, options.configuration.client_secret
email_providerscredentials (all string values)
authentication_methodstotp_secret
migration_sourcescredentials.client_secret
actionsthe value of each entry in secrets
action_versionsthe value of each entry in secrets
hook_codeevery value in secrets

Non-sensitive keys (e.g. connections.options.client_id) are left in plaintext so they remain queryable and inspectable.

Platform-scoped rows ​

Secrets of scope: "platform" rows are encrypted like any other, with the same key ring. The tenant's management API hides those rows, but decryption picks the key from the ring by the key id inside the ciphertext, and action code is not encrypted, so a process that holds the ring and the database can read both. See platform-scoped rows.

Not covered ​

Private keys are intentionally not encrypted by this mechanism: keys.pkcs7 (JWT signing keys) and dkim_private_key. These are better protected with envelope encryption and are left for a future iteration.

How it works ​

Encrypted values are AES-256-GCM ciphertext with a random 96-bit IV, stored as enc:v1:<base64url(iv ‖ ciphertext ‖ tag)>. The enc:v1: prefix is a version marker.

Reads are prefix-aware: a stored value without the enc:v1: prefix is treated as legacy plaintext and returned unchanged. This means existing rows keep working after you enable encryption — they migrate to ciphertext lazily, the next time the field is written. No migration script is required.

Key rotation ​

V1 supports a single active key. Rotating to a new key requires re-encrypting existing rows; a dedicated enc:v2: format and re-encryption tooling are planned. Until then, treat the key as long-lived and store it in a secrets manager (Cloudflare Workers secret, AWS Secrets Manager, etc.). Losing the key makes encrypted fields unrecoverable.

Dual-licensed: AGPL-3.0-only or commercial license.