Envelope Encryption: MEK/DEK Pattern

January 6, 2026 5 min read

Why Envelope Encryption?

Most applications encrypt data with a single key. If that key is compromised, everything is exposed.
TimOS needed something stronger – the ability to say with confidence: “We cannot read your data.”

Envelope encryption solves this with a two-tier key hierarchy:

┌─────────────────────────────────────────────────────────┐
│                    ENVELOPE ENCRYPTION                   │
├─────────────────────────────────────────────────────────┤
│                                                          │
│   MASTER ENCRYPTION KEY (MEK)                           │
│   ├── Stored in environment variable                    │
│   ├── Never stored in database                          │
│   └── Used to encrypt DEKs                              │
│                                                          │
│         ↓ encrypts                                       │
│                                                          │
│   DATA ENCRYPTION KEY (DEK) - One per user              │
│   ├── Generated on user signup                          │
│   ├── Stored encrypted in platform DB                   │
│   └── Used to encrypt user's actual data                │
│                                                          │
│         ↓ encrypts                                       │
│                                                          │
│   USER CONTENT - In vault database                      │
│   ├── Journal entries                                    │
│   ├── Pillar data                                        │
│   ├── Feels entries                                      │
│   └── Weekly recaps                                      │
│                                                          │
└─────────────────────────────────────────────────────────┘
The Key Insight

Crypto-shredding: When a user deletes their account, we don’t need to find and delete
all their encrypted content across multiple tables. We simply delete their DEK. Without the key, their data becomes
cryptographically unrecoverable – even if the encrypted content remains in the database.

The Implementation

Key Generation

When a user signs up, we generate a unique 256-bit DEK using cryptographically secure random bytes:

// api/src/services/encryption.ts
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const KEY_LENGTH = 32; // 256 bits

export function generateDEK(): string {
  return crypto.randomBytes(KEY_LENGTH).toString('hex');
}

export function encryptDEK(dek: string, mek: string): {
  encryptedKey: string;
  iv: string;
} {
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(
    ALGORITHM,
    Buffer.from(mek, 'hex'),
    iv
  );

  let encrypted = cipher.update(dek, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const authTag = cipher.getAuthTag();

  return {
    encryptedKey: encrypted + authTag.toString('hex'),
    iv: iv.toString('hex'),
  };
}

Encrypting User Content

Every piece of user content goes through the same encryption process. We use AES-256-GCM because it provides
both confidentiality and authenticity (the auth tag ensures the data hasn’t been tampered with):

export function encrypt(
  plaintext: string,
  dekHex: string
): { ciphertext: string; iv: string } {
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(
    ALGORITHM,
    Buffer.from(dekHex, 'hex'),
    iv
  );

  let encrypted = cipher.update(plaintext, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const authTag = cipher.getAuthTag();

  return {
    ciphertext: encrypted + authTag.toString('hex'),
    iv: iv.toString('hex'),
  };
}

export function decrypt(
  ciphertext: string,
  iv: string,
  dekHex: string
): string {
  const authTag = Buffer.from(
    ciphertext.slice(-AUTH_TAG_LENGTH * 2),
    'hex'
  );
  const encryptedData = ciphertext.slice(0, -AUTH_TAG_LENGTH * 2);

  const decipher = crypto.createDecipheriv(
    ALGORITHM,
    Buffer.from(dekHex, 'hex'),
    Buffer.from(iv, 'hex')
  );
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

Database Schema

The dual-database architecture keeps encryption keys separate from encrypted content:

// Platform DB - stores encrypted DEKs
model UserDEK {
  id              String   @id @default(uuid())
  userId          String   @unique @map("user_id")
  encryptedKey    String   @map("encrypted_key")
  keyIv           String   @map("key_iv")
  keyVersion      Int      @default(1) @map("key_version")
  createdAt       DateTime @default(now())
  rotatedAt       DateTime?

  user User @relation(fields: [userId], references: [id])
  @@map("user_deks")
}

// Vault DB - stores encrypted content
model DailyEntry {
  id              String   @id @default(uuid())
  userId          String   @map("user_id")
  date            DateTime @db.Date

  // All content stored encrypted
  encryptedContent String  @db.LongText @map("encrypted_content")
  contentIv        String  @map("content_iv")

  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@unique([userId, date])
  @@map("daily_entries")
}

The Encryption Flow

Here’s what happens when a user saves a journal entry:

Step 1: Retrieve User’s DEK
const userDek = await platformDb.userDEK.findUnique({
  where: { userId }
});

const dek = decryptDEK(
  userDek.encryptedKey,
  userDek.keyIv,
  process.env.MASTER_ENCRYPTION_KEY
);

Step 2: Encrypt the Content
const content = JSON.stringify({
  notes: "Today's reflection...",
  mood: "focused",
  energyLevel: 8
});

const { ciphertext, iv } = encrypt(content, dek);

Step 3: Store in Vault
await vaultDb.dailyEntry.create({
  data: {
    userId,
    date: new Date(),
    encryptedContent: ciphertext,
    contentIv: iv
  }
});

Important Security Note

The DEK is decrypted in memory only when needed and should never be logged or stored in plaintext.
We clear it from memory after use. The MEK never leaves the environment variable.

Key Rotation

For added security, we support DEK rotation. When triggered, the process:

  1. Generates a new DEK for the user
  2. Decrypts all user content with the old DEK
  3. Re-encrypts all content with the new DEK
  4. Updates the DEK record in the platform database
  5. Increments the key version
export async function rotateDEK(userId: string): Promise {
  const newDek = generateDEK();
  const oldDek = await getUserDEK(userId);

  // Re-encrypt all user content
  const entries = await vaultDb.dailyEntry.findMany({
    where: { userId }
  });

  for (const entry of entries) {
    const content = decrypt(
      entry.encryptedContent,
      entry.contentIv,
      oldDek
    );
    const { ciphertext, iv } = encrypt(content, newDek);

    await vaultDb.dailyEntry.update({
      where: { id: entry.id },
      data: {
        encryptedContent: ciphertext,
        contentIv: iv
      }
    });
  }

  // Update the DEK
  const { encryptedKey, iv } = encryptDEK(
    newDek,
    process.env.MASTER_ENCRYPTION_KEY
  );

  await platformDb.userDEK.update({
    where: { userId },
    data: {
      encryptedKey,
      keyIv: iv,
      keyVersion: { increment: 1 },
      rotatedAt: new Date()
    }
  });
}

Performance Considerations

Encryption adds overhead, but with proper implementation the impact is minimal:

Optimizations
  • DEK cached in memory per request
  • Batch operations for bulk updates
  • Async encryption doesn’t block
  • AES-256-GCM is hardware-accelerated

Trade-offs
  • Can’t search encrypted content
  • Key rotation is expensive
  • Forgot-key = lost data
  • Slightly larger storage (IV + auth tag)

Benchmark Results

On a typical API server, encryption adds ~2-5ms per request for content up to 10KB.
For a journal entry (typically 1-2KB), the overhead is negligible.

What We Can Honestly Say

With this implementation, we can make strong privacy claims:

  • Database breach: Attackers get ciphertext and encrypted DEKs – useless without MEK
  • Admin access: We see encrypted blobs, not user content
  • Subpoena: We can only provide encrypted data without the ability to decrypt
  • Account deletion: DEK deletion = cryptographic data destruction

Key Takeaways

  • Envelope encryption provides defense in depth
  • Per-user keys enable true data isolation
  • Crypto-shredding simplifies account deletion
  • AES-256-GCM provides both confidentiality and integrity
  • Dual databases keep keys separate from content
Secret Link