End-to-End Encryption

April 24, 2026 · View on GitHub

⚠️ End-to-end encryption is in Preview. It has not yet undergone a cryptography audit and is subject to further refinements.

Thunderbolt supports optional zero-knowledge end-to-end encryption: all user data is encrypted client-side before sync and decrypted client-side after download. The server stores only ciphertext and wrapped keys — it cannot read user data even if compelled or breached.

For the sync pipeline integration, see powersync-sync-middleware.md.


Configuration

E2EE is disabled by default. The backend is the single source of truth:

VariableWhereDefaultEffect when enabled
E2EE_ENABLEDBackend .envfalseRequires device trust flow before allowing sync; frontend encrypts/decrypts data, shows setup wizard, generates keys
# Backend (backend/.env)
E2EE_ENABLED=true

The frontend reads this flag from the backend's GET /v1/config endpoint at app initialization and caches it in localStorage for offline use. No frontend environment variable is needed.

When disabled (default), sync works without encryption — no setup wizard, no key generation, no recovery key. The backend auto-trusts devices and skips the envelope flow. The encryption API endpoints remain available but are not called.

Frontend control point: isEncryptionEnabled() in src/db/encryption/config.ts reads the cached flag from localStorage. The companion needsSyncSetupWizard() helper combines the encryption-enabled check with the CK-exists check — it returns true only when E2EE is on and no Content Key has been set up yet. Both the sign-in flow and the sync toggle use this helper to decide whether to show the setup wizard or enable sync directly.

Backend control point: e2eeEnabled in backend/src/config/settings.ts. When false, validateDeviceForSync() skips the trust check and issuePowerSyncToken() auto-trusts devices on upsert.


Key Concepts

ConceptDescription
Device key pairEach device generates an ECDH P-256 key pair and an ML-KEM-768 key pair when sync is enabled. Private keys never leave the device.
Content key (CK)A single AES-256-GCM key that encrypts all user data. Identical across all devices of the same user.
Device envelopeThe CK wrapped using hybrid ECDH + ML-KEM for a specific device. Only that device's private keys can unwrap it.
Recovery keyCK encoded as a 24-word BIP-39 mnemonic. Shown once at first setup. The only way to recover data if all devices are lost.
CanaryA fixed plaintext encrypted with CK, stored server-side. Used to verify a recovery key is correct and to detect whether encryption is set up.

Key Hierarchy

There's one content key per account. Each device has its own keypair. The CK is wrapped separately for every device using a hybrid envelope. Each device unwraps its own envelope to arrive at the same CK.

                         ┌─────────────────────────┐
                         │            CK           │
                         │  (one key, all records) │
                         └───────────┬─────────────┘
                    wrapped separately for each device
          ┌──────────────────┬──────────────────┬─────┐
          ▼                  ▼                  ▼
 ┌────────────────┐ ┌────────────────┐ ┌────────────────┐
 │ envelope       │ │ envelope       │ │ envelope       │
 │ device 1       │ │ device 2       │ │ device 3       │
 └───────┬────────┘ └───────┬────────┘ └───────┬────────┘
  unwrap with       unwrap with        unwrap with
  private key 1     private key 2      private key 3
          │                  │                  │
          ▼                  ▼                  ▼
          CK                 CK                 CK
      (identical)       (identical)        (identical)

Wire Format

Encrypted column values on the wire are written as:

__enc:<iv-base64>:<ciphertext-base64>

The download and upload middleware both read from encryptedColumnsMap in src/db/encryption/config.ts — a single source of truth for which columns are encrypted.

User Flows

ScenarioWhat happens
First deviceUser enables sync → device generates key pair and CK → wraps CK for itself → recovery key is shown once.
Additional deviceNew device generates its own keys → waits for approval → a trusted device wraps CK for it → new device unwraps and starts syncing.
Returning deviceKey pair still present locally, CK missing → fetches own envelope → unwraps → sync resumes.
Recovery keyUser enters 24-word phrase → CK decoded → canary verified → new envelope created for this device → sync resumes.
Sign outAll local keys cleared → next sign-in is treated as a new device.
Revoke deviceEnvelope deleted server-side, revoked_at set → device can no longer decrypt or sync.

Adding a New Encrypted Column

To encrypt a new column, add the table and column name to encryptedColumnsMap in src/db/encryption/config.ts. The existing encryptionMiddleware handles every column in the map automatically — both download decryption and upload encryption.

Key Files

FileRole
src/crypto/primitives.tsHybrid key wrapping + AES-256-GCM primitives
src/crypto/key-storage.tsIndexedDB-backed key storage
src/crypto/canary.tsCanary creation and verification
src/crypto/recovery-key.tsBIP-39 mnemonic encode/decode
src/db/encryption/config.tsEncrypted columns map (single source of truth)
src/db/encryption/codec.tsAES-GCM codec with CK cache
src/services/encryption.tsService layer orchestrating all flows
backend/src/api/encryption.tsBackend encryption API routes
backend/src/dal/encryption.tsBackend data access layer

Sync Pipeline Integration

Encryption is implemented as a PowerSync transform-middleware. On Chrome/Edge/Firefox it runs inside a custom SharedWorker so the CK stays in one place across tabs; on Safari and Tauri it runs on the main thread because those environments don't support SharedWorker. See Multi-Device Sync and powersync-sync-middleware.md for the full architecture.