Olympus Docs
CookbookSecrets & encryption

Per-user encryption keys

Encrypt user data so even DB compromise doesn't leak content

By default, Olympus encrypts identity traits with a single master key. If DB and that key both leak, all traits leak. Higher assurance: derive per-user keys so a single key leak doesn't reveal everyone.

Pattern

Master Key (in secrets vault, never in DB)


KDF (HKDF-SHA256)


Per-User Key = HKDF(MasterKey, salt=user_id, info="user-data-encryption")


Encrypt user data with this key.

Result:

  • DB stores ciphertext + salt (user_id).
  • User key is derived on demand.
  • Single user key compromise = that user.
  • Master key compromise = all users.

Not perfect, master key remains the linchpin. But removes correlation: one cracked record doesn't immediately reveal others.

Implementation

import { hkdfSync, createCipheriv, createDecipheriv } from "crypto";

const masterKey = Buffer.from(process.env.MASTER_KEY!, "base64");  // 32 bytes

function deriveUserKey(userId: string): Buffer {
  return Buffer.from(hkdfSync("sha256", masterKey, Buffer.from(userId), "user-data-encryption", 32));
}

function encryptUserData(userId: string, plaintext: string): string {
  const key = deriveUserKey(userId);
  const iv = crypto.randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key, iv);
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  return Buffer.concat([iv, ciphertext, tag]).toString("base64");
}

function decryptUserData(userId: string, encrypted: string): string {
  const data = Buffer.from(encrypted, "base64");
  const iv = data.slice(0, 12);
  const tag = data.slice(-16);
  const ciphertext = data.slice(12, -16);
  
  const key = deriveUserKey(userId);
  const decipher = createDecipheriv("aes-256-gcm", key, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
}

What to encrypt

NOT email or basic profile (you need to query by them).

DO encrypt:

  • Free-text fields (bio, notes).
  • Phone numbers (semi-sensitive).
  • Address details.
  • Medical / financial info.
CREATE TABLE user_profiles (
  identity_id UUID PRIMARY KEY,
  bio_encrypted TEXT,      -- base64 of (iv || ciphertext || tag)
  address_encrypted TEXT,
  phone_encrypted TEXT
);

App reads:

const profile = await db`SELECT * FROM user_profiles WHERE identity_id = ${userId}`.first();
return {
  bio: profile.bio_encrypted ? decryptUserData(userId, profile.bio_encrypted) : null,
  address: profile.address_encrypted ? decryptUserData(userId, profile.address_encrypted) : null,
};

Searching encrypted fields

Encrypted fields can't be searched / indexed by content.

Options:

  • Don't search them.
  • Hash for equality: phone_hash = sha256(phone). Search by hash.
  • Field-level deterministic encryption (less secure but indexable): use AES-SIV.

Most encrypted fields shouldn't need search. If they do, reconsider what to encrypt.

Key rotation

Master key rotation: see Encryption key rotation. For per-user, similar pattern:

master_v1 → per-user keys → encrypted data
master_v2 → per-user keys → encrypted data (new)

During rotation, re-encrypt user data with v2-derived keys.

For long deployments, lazy re-encryption: re-encrypt on user's next interaction. Avoids bulk processing.

What this doesn't protect

  • Master key compromise (catastrophic).
  • Memory dumps of running services (keys in RAM).
  • Side-channel attacks.
  • Bugs that leak plaintext.

It DOES protect:

  • Cold DB exfiltration without app server access.
  • Mid-tier compromise (DB read access, no key access).

Compared to TDE

Postgres has Transparent Data Encryption (TDE) via filesystem encryption. Protects against disk theft. Doesn't protect against DB-level access (running queries).

Per-user app-level encryption protects against DB access too (you'd need both DB access AND key).

Use TDE for at-rest disk encryption. Use per-user app-level on top, for sensitive fields.

Olympus's approach

Olympus's encryption:

  • Hashed passwords (Argon2id).
  • AES-256-GCM for traits.
  • HKDF-SHA256 for key derivation.
  • Master key from OLYMPUS_ENCRYPTION_KEY_PRIMARY.

Default: single key (not per-user). For per-user, build on top in your app's tables, not in Kratos's identities table.

For per-tenant (your B2B customers each have their own key), see ADR 0006.

Performance

Key derivation: ~10 µs. Encrypt/decrypt: ~1 µs / 100 bytes.

Negligible cost per request. No measurable impact.

Audit

Track encryption events:

audit({ event: "user_data_decrypted", target_id: userId, by: actor_id });

If someone is fetching/decrypting many users' data, that's suspicious.

On this page