Skip to content

Per-Tenant Key Derivation

Mindful Auth uses per-tenant key derivation with HKDF (HMAC-based Key Derivation Function). Each tenant gets a unique encryption key derived from:

tenant_key = HKDF(master_key, tenant_salt, "mindful-auth-tenant-key")

This provides improved security isolation between tenants while maintaining a single master secret.


A salt is random data added to cryptographic operations to ensure uniqueness. Think of it like adding a unique ingredient to each recipe:

  • Without salt: Same input → same output (predictable)
  • With salt: Same input + different salt → different output (unique per tenant)

Example:

Master Key alone: "secret123" → Same derived key for all tenants
Master Key + Salt A: "secret123" + "xyz..." → Unique key for Tenant A
Master Key + Salt B: "secret123" + "abc..." → Unique key for Tenant B

Key properties:

  • Salts are random (generated once per tenant during onboarding)
  • Salts are not secret (stored openly alongside encrypted data)
  • Salts ensure unique keys even when master key is shared
  • An attacker needs both master key AND salt to derive a tenant’s key

  • Each tenant has a unique encryption key
  • Compromise of one tenant’s encrypted data doesn’t directly reveal other tenants’ keys
  • Attacker needs both master key AND tenant salt to derive any specific tenant’s key
  • Master key compromise still affects all tenants
  • Attacker with master key + tenant salts can derive tenant keys
  • Tenant salts are not secret (stored alongside encrypted data in Cloudflare KV and Tape admin workspace)

// Unique key per tenant using HKDF
key = HKDF(master_key, tenant_salt, "mindful-auth-tenant-key", SHA-256)

All encrypted data follows the format:

[iv(12 bytes)] + [ciphertext]
  • iv: 12-byte random initialization vector (AES-GCM)
  • ciphertext: AES-GCM encrypted data with authentication tag
  • tenantSalt: 32 random bytes, base64 encoded
  • Stored in:
    • Cloudflare KV: tenantConfig:{hostname}tenantSalt field
    • Tape: Hostnames app → tenant_salt field

// Generate new per-tenant salt
generateTenantSalt() → string (base64)
// Encrypt with per-tenant key
encryptTenantSecret(plaintext, env, tenantSaltB64) → ciphertext
// Decrypt with per-tenant key
decryptTenantSecret(ciphertextB64, env, tenantSaltB64) → plaintext
FieldStorage LocationDescription
encrypted2faKeyCloudflare KV + TapeTenant’s 2FA encryption key
encryptedInternalApiKeyCloudflare KV + TapeAPI key for lock/unlock endpoint
encryptedTurnstileSecretKeyCloudflare KV + TapeCloudflare Turnstile secret
Backend API KeyCloudflare KV + TapeencryptedTapeApiKey or encryptedD1ApiToken

All tenants automatically use per-tenant key derivation:

  1. Generate tenant salt:

    const tenantSalt = generateTenantSalt();
  2. Encrypt all secrets with salt:

    const encrypted2faKey = await encryptTenantSecret(twoFAKey, env, tenantSalt);
    const encryptedInternalApiKey = await encryptTenantSecret(internalApiKey, env, tenantSalt);
    const encryptedTurnstileSecretKey = await encryptTenantSecret(turnstileSecretKey, env, tenantSalt);
  3. Store salt alongside encrypted data:

    // Cloudflare KV storage
    await env.KV_HOSTNAMES.put(`tenantConfig:${hostname}`, JSON.stringify({
    ...config,
    tenantSalt,
    encrypted2faKey,
    encryptedInternalApiKey,
    encryptedTurnstileSecretKey
    }));

When multiple hostnames share the same backend app ID:

  • The first hostname’s tenantSalt is reused
  • All encrypted keys are copied from the existing hostname
  • This ensures users can authenticate across all portals with consistent encryption
// In onboardHostname.js
if (existingHostnameWithSameAppId) {
// Reuse salt and encrypted keys from existing hostname
tenantSalt = existingConfig.tenantSalt;
encrypted2faKey = existingConfig.encrypted2faKey;
encryptedInternalApiKey = existingConfig.encryptedInternalApiKey;
}

Storage Strategy: CloudflareKV for Speed, Tape for Audit Trail

Section titled “Storage Strategy: CloudflareKV for Speed, Tape for Audit Trail”
  • Cloudflare KV storage: Fast in-memory cache used during authentication flows

    • Tenant config lookups happen via getTenantConfig()
    • All encrypted secrets are stored and retrieved from Cloudflare KV
    • Used for every login, password reset, 2FA operation
    • Purpose: Sub-millisecond response times
  • Tape storage (Hostnames app): Persistent source of truth for admin operations

    • Stores same encrypted data as Cloudflare KV (synchronized during onboarding)
    • Used by admin endpoints (getTenantHostname, updateHostname, etc.)
    • Provides audit trail and administrative visibility
    • Purpose: Long-term persistence, admin console access

Key point: Runtime authentication flows ALWAYS read from Cloudflare KV, not Tape. Cloudflare KV contains the encrypted data needed for decryption during auth operations.


The tenant salt enables key derivation but doesn’t provide security on its own. An attacker with:

  • Only tenant salt: Cannot derive key (needs master key)
  • Only master key: Cannot derive key (needs tenant salt)
  • Both: Can derive tenant key

Each tenant’s encrypted data is isolated at the key level:

  • Tenant A’s salt + master key → Tenant A’s key
  • Tenant B’s salt + master key → Tenant B’s key

An attacker with Tenant A’s encrypted data and salt still cannot decrypt Tenant B’s data (different salt = different key).


Cloudflare KV is a globally distributed, low-latency key-value store that replicates data across Cloudflare’s edge network. It’s designed for fast reads/writes with eventual consistency.

Encryption at rest:

  • All data stored in Cloudflare KV is encrypted by Cloudflare using industry-standard encryption
  • Encryption keys are managed by Cloudflare’s infrastructure

Access control:

  • KV namespaces are isolated per Cloudflare Worker project
  • Access requires valid authentication to the Cloudflare account
  • No direct public access to KV data (only through Worker code)

Why it’s safe for encrypted secrets:

  • Your encrypted data is protected by two layers of encryption:
    1. Your application layer: Encrypted with per-tenant derived keys (HKDF + AES-GCM)
    2. Cloudflare layer: Encrypted by Cloudflare at rest
  • Even if Cloudflare infrastructure is compromised, the data is still encrypted with your keys
  • The master key is never stored in KV (kept in Worker environment secrets)
  • Tenant salts are non-secret; their exposure doesn’t compromise encryption

Threat model:

  • Protected against: Cloudflare infrastructure compromise, data breaches at storage level, network interception
  • Protected against: Unauthorized access to KV (requires account authentication)
  • ⚠️ Not protected against: Master key compromise (attacker can decrypt all tenant data)
  • Master key: Stored in Cloudflare Worker environment secrets (separate from KV)
  • Encrypted data: Stored in Cloudflare KV (with public visibility acceptable since encrypted)
  • Tenant salts: Stored with encrypted data in Cloudflare KV (non-secret by design)

This separation ensures that even if someone gains read access to Cloudflare KV, they cannot decrypt tenant secrets without the master key.

The master key is stored in Cloudflare Worker environment variables/secrets, which are separate from KV and provide stronger protection:

Storage location:

  • Master key never leaves Cloudflare’s infrastructure
  • Stored as an encrypted environment variable in Cloudflare’s secure vault
  • Only loaded into Worker memory at runtime (not persisted to disk or KV)

Access difficulty (why it’s hard to compromise):

  • Not accessible via KV API - Environment secrets are completely separate from KV storage

  • Not accessible via Worker code - You can read your own environment secrets, but an attacker would need to:

    1. Compromise the Cloudflare account (requires stealing 2FA credentials + password)
    2. Gain administrative access to the Worker project
    3. Deploy malicious code that exfiltrates the secret
    4. Or compromise Cloudflare’s infrastructure directly (extremely difficult)
  • Not accessible via network interception - The secret never travels over the network; it’s injected directly into the Worker runtime

  • Not accessible via Worker logs

Attack chain required: To compromise the master key, an attacker would need:

  1. Valid Cloudflare account credentials (email + password + 2FA bypass)
  2. Permission to modify the Worker code
  3. Ability to deploy new code (requires another 2FA step)
  4. AND either:
    • Read the environment variable (if Cloudflare API allows it, which it doesn’t by default for security)
    • Or modify the code to exfiltrate the secret

Industry comparison: This is equivalent to how major cloud providers (AWS, Azure, GCP) protect secrets:

  • AWS Secrets Manager: Secrets stored in encrypted vault, not in code or logs
  • Azure Key Vault: Encryption keys never leave the secure enclave
  • Cloudflare: Environment secrets stored separately with restricted access

Risk assessment:

  • 🔴 High risk if: Attacker compromises Cloudflare account + 2FA
  • 🟡 Medium risk if: Attacker gains code deployment access (still need account + 2FA to do this)
  • 🟢 Low risk from: KV data breach, network interception, log leaks