End-to-End Encryption
⚠️ 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. Coverage is per column and opt-in: the columns listed in encryptedColumnsMap are encrypted client-side before sync and decrypted client-side after download, and for those columns the server stores only ciphertext and wrapped keys — it cannot read them even if compelled or breached. Everything else syncs in plaintext. See What Is and Isn’t Encrypted for the current set.
For the sync pipeline integration, see powersync-sync-middleware.md.
Configuration
E2EE is disabled by default. The backend is the single source of truth:
| Variable | Where | Default | Effect when enabled |
|---|---|---|---|
E2EE_ENABLED |
Backend .env |
false |
Requires device trust flow before allowing sync; frontend encrypts/decrypts data, shows setup wizard, generates keys |
# Backend (backend/.env)E2EE_ENABLED=trueThe 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
| Concept | Description |
|---|---|
| Device key pair | Each 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 envelope | The CK wrapped using hybrid ECDH + ML-KEM for a specific device. Only that device’s private keys can unwrap it. |
| Recovery key | CK encoded as a 24-word BIP-39 mnemonic. Shown once at first setup. The only way to recover data if all devices are lost. |
| Canary | A 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>Upload encryption reads encryptedColumnsMap in src/db/encryption/config.ts to decide what to encrypt. Download decryption does not: EncryptionMiddleware decrypts any string value carrying the __enc: prefix, whatever its column. The prefix is the authoritative signal precisely so that a stale desktop bundle — whose compiled-in map predates a newly encrypted column — still decrypts correctly instead of writing ciphertext into SQLite.
What Is and Isn’t Encrypted
encryptedColumnsMap is the single source of truth. encodeForUpload (src/db/encryption/upload-encoder.ts) looks the operation’s table up in the map and returns the row unchanged when there is no entry, so a synced table absent from the map uploads in plaintext — the silent default when someone adds a table.
| Table | Encrypted columns |
|---|---|
settings |
value |
chat_threads |
title |
chat_messages |
content, parts, cache, metadata |
tasks |
item |
models |
name, model, url, vendor, description |
prompts |
title, prompt |
triggers |
trigger_time |
model_profiles |
the twelve prompt-override and provider_options columns |
devices |
name |
skills |
name, label, description, instruction |
projects |
name, description, instructions |
agents |
(none — the whole table syncs in plaintext) |
Two gaps are deliberate and one is not.
- Structural columns stay plaintext across every table. Ids, foreign keys,
deleted_at, ordering, and boolean flags are what sync rules filter on and what local queries index; encrypting them would break both. projects.iconandprojects.pinned_orderstay plaintext because neither carries user-authored content — the icon is a single emoji picked from a fixed set.agentshas no entry at all, so a user-created ACP agent’sname,url,descriptionandiconreach the server in the clear even with E2EE on. That is an omission rather than a decision; the table is synced (shared/powersync-tables.ts) and its columns are user-authored (src/db/tables.ts,backend/src/db/powersync-schema.ts).
User Flows
| Scenario | What happens |
|---|---|
| First device | User enables sync → device generates key pair and CK → wraps CK for itself → recovery key is shown once. |
| Additional device | New device generates its own keys → waits for approval → a trusted device wraps CK for it → new device unwraps and starts syncing. |
| Returning device | Key pair still present locally, CK missing → fetches own envelope → unwraps → sync resumes. |
| Recovery key | User enters 24-word phrase → CK decoded → canary verified → new envelope created for this device → sync resumes. |
| Sign out | All local keys cleared → next sign-in is treated as a new device. |
| Revoke device | Envelope deleted server-side, revoked_at set → device can no longer decrypt or sync. |
Device Trust Lifecycle
A device row (devices in backend/src/db/powersync-schema.ts) encodes its state in three columns — trusted, approval_pending, revoked_at — rather than a single status enum, so every state is a combination:
| State | trusted |
approval_pending |
revoked_at |
|---|---|---|---|
| Pending approval | false | true | null |
| Trusted | true | false | null |
| Denied or self-cancelled | false | false | null |
| Revoked | false | false | set |
registerDevice inserts pending, and on conflict resets an existing non-revoked row back to pending with fresh public keys — a re-registered device must go through approval again. From there:
- Pending → trusted via
markDeviceTrusted, which the envelope route calls after storing the wrapped CK. ItsWHERErequiresapproval_pending = true, so if a deny committed first the update matches zero rows and the caller gets a 403 instead of silently trusting a denied device. - Pending → denied via
denyDevice, from either a trusted device (POST /devices/:deviceId/deny) or the pending device itself (POST /devices/me/cancel-pending). Denial also clearsnode_id/node_id_attested_at, andsetDeviceNodeIdexcludes denied and revoked rows — a denied peer cannot re-bind a P2P identity. - Any → revoked via
POST /v1/account/devices/:id/revokeinbackend/src/api/account.ts(see powersync-account-devices.md), which in one transaction deletes the envelope, callsrevokeDeviceto stamprevoked_atand clear both trust flags and the P2P binding, then revokes the device’s sessions. When encryption metadata exists the route also demands a canary secret and a trustednormalcaller, so a device with no CK cannot revoke one that has it.
Device cap
maxActiveDevicesPerUser = 10 in backend/src/dal/devices.ts. countActiveDevices counts only trusted, non-revoked rows — pending devices are deliberately excluded (THU-502) so a device waiting for approval can’t lock a user out of registering. That exclusion is why the cap is enforced twice: once at registration and again at approval, in the envelope route. Without the second check a user could register eleven pending devices and approve them all.
POST /devices answers 422 { "error": "Device limit reached" }. The CLI registration path (backend/src/api/account.ts) has its own 422 with the machine-readable { "code": "DEVICE_LIMIT_REACHED" }.
Device types
device_type is 'normal' | 'bridge' | 'cli' and is server-set only — it is in the uploadDenyColumns list for devices in backend/src/dal/powersync.ts, alongside trusted, approval_pending, revoked_at, the public keys and node_id. A client therefore cannot relabel its own device a bridge through a raw PowerSync upload. bridge and cli are each written by exactly one route; normal is the column default, so any route that upserts a device without naming a type produces one.
| Type | Created by | Notes |
|---|---|---|
normal |
POST /devices, or issuePowerSyncToken on first token issue |
An app install. On the E2EE path it holds a device key pair and an envelope; with E2EE off the token route upserts it trusted and neither exists. |
bridge |
POST /devices/bridge |
A headless ACP/MCP bridge, keyed on a hash of (userId, nodeId) so re-registration is idempotent. Inserted trusted — the user added it deliberately. Holds no CK. |
cli |
PUT /v1/account/devices/cli |
Account-only. Excluded from every encryption route and from the node-id and allowlist queries. |
API Endpoints
All routes below are served by backend/src/api/encryption.ts under the global /v1 prefix (backend/src/index.ts) and all require an authenticated session.
Six of them additionally read the caller’s own device id from the X-Device-ID request header and answer 400 when it is absent. That header is client-set and therefore never an authorization on its own: what actually gates the trust-sensitive routes is a canary secret — proof that the caller can decrypt the account canary, and so holds the Content Key. A pending device cannot produce one. The one exception, POST /devices/me/node-id, takes no canary and instead pins the caller to the server-side session.deviceId.
| Method | Path | X-Device-ID |
Purpose |
|---|---|---|---|
POST |
/devices |
— | Register or re-identify this device with its ECDH and ML-KEM public keys. Binds the session to the device. |
POST |
/devices/:deviceId/envelope |
required | Store a wrapped CK for a device, promoting it from pending to trusted. |
GET |
/devices/me/envelope |
required | Fetch this device’s own wrapped CK. |
GET |
/encryption/canary |
— | Fetch canaryIv/canaryCtext for recovery-key verification. Its 404 doubles as the “is E2EE set up?” probe. |
POST |
/devices/:deviceId/deny |
required | A trusted device rejects a pending device. |
POST |
/devices/me/cancel-pending |
required | A pending device withdraws its own request. |
POST |
/devices/:deviceId/node-id |
required | A trusted device attests another device’s iroh endpoint identity. |
POST |
/devices/me/node-id |
required | A device self-enrolls its own iroh endpoint identity. |
GET |
/devices/allowlist |
— | The account’s trusted, non-revoked node_ids. A bridge fetches and caches it to auto-allow same-account peers. |
POST |
/devices/bridge |
— | Register a bridge device for the account. |
DELETE |
/devices/:deviceId |
— | Hard-delete a device row. Only a bridge that is already revoked qualifies. |
POST /devices/:deviceId/envelope
The route that actually grants trust, and the one with the most conditions. It accepts exactly three shapes:
- First-device bootstrap — no envelopes exist for the account and the caller is storing for itself.
canaryIv,canaryCtextandcanarySecretare all required, because without a canary there is no recovery path later. If encryption metadata already exists the supplied secret must verify against it, which stops a re-bootstrap from resetting the account’s E2EE state even if the revocation checks were bypassed. - Self-recovery — caller and target are the same device and the canary secret verifies. This is the path a device takes after the user enters a recovery key.
- Approval — a trusted, non-CLI caller stores an envelope for someone else’s pending row, with a valid canary secret.
Storing for an already-trusted device from a different caller is 409; only a device may re-key its own envelope. Once the envelope is written, the trusted transition runs only for a target that wasn’t already trusted — re-keying a trusted device changes nothing else, and running markDeviceTrusted on it would match zero rows and be misread as a revoke.
Notable failure codes: 400 missing header or incomplete bootstrap canary; 403 revoked device, missing or invalid canary secret, untrusted caller, or a concurrent revoke or deny; 404 unknown device, another user’s device, or a CLI device; 409 envelope overwrite of a trusted device.
Adding a New Encrypted Column
To encrypt a new column, add the table and column name to encryptedColumnsMap in src/db/encryption/config.ts. That is the only change needed: encodeForUpload encrypts every column in the map on upload, and encryptionMiddleware decrypts by __enc: prefix on download, so neither needs to know about the new column ahead of time. Rows already synced in plaintext are not retro-encrypted — only writes made after the change are.
Key Files
| File | Role |
|---|---|
src/crypto/primitives.ts |
Hybrid key wrapping + AES-256-GCM primitives |
src/crypto/key-storage.ts |
IndexedDB-backed key storage |
src/crypto/canary.ts |
Canary creation and verification |
src/crypto/recovery-key.ts |
BIP-39 mnemonic encode/decode |
src/db/encryption/config.ts |
Encrypted columns map (single source of truth) |
src/db/encryption/codec.ts |
AES-GCM codec with CK cache |
src/services/encryption.ts |
Service layer orchestrating all flows |
backend/src/api/encryption.ts |
Backend encryption API routes |
backend/src/dal/encryption.ts |
Backend 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 in a dedicated Worker, which stands in for the SharedWorker those environments can’t use (sync: { worker: () => createDedicatedSyncWorker(dbFilename) } in src/db/powersync/database.ts). Either way download decryption happens off the UI thread. Upload encryption does not: encodeForUpload runs in ThunderboltConnector.uploadData on the main thread, so the CK cache exists there too — which is why invalidateCKCache() broadcasts over a BroadcastChannel rather than clearing one copy. See Multi-Device Sync and powersync-sync-middleware.md for the full architecture.