DEV Community

Cover image for How We Added E2E Encryption on Top of a Local-First Architecture

How We Added E2E Encryption on Top of a Local-First Architecture

Doszhan Mengaliyev on May 15, 2026

I used to track my finances in an app. Salary, loans, small transfers, all of it. At some point I got curious whether the team behind it could actu...
Collapse
 
superfunicular profile image
Super Funicular

Strong agreement on the threat model — "data should be inaccessible because the team technically cannot read it, not because they promise to behave" is exactly the right framing. The two-tables-with-localOnly pattern is clean.

One angle that pushes it further: if the sync target is just another device on the same LAN (phone ↔ laptop, two phones on the same wifi), you can drop the sync engine entirely and run an embedded HTTP server inside the app. The server stays local, never opens a port to the internet, and the only thing that leaves the device is a response to a request from a device that already knows the LAN IP.

We took that route building Background Camera RemoteStream — a recording app that streams the live camera feed to a browser on the same network. No PowerSync, no encrypted sync table, no cloud backend at all. The ciphertext doesn't need encryption at rest because it never gets handed to anyone we don't control: play.google.com/store/apps/details...

Honest tradeoff: this only works when "another device on the LAN" is an acceptable sync target. The moment users need device-to-device sync across cellular networks, PowerSync + your two-table pattern is the right move and the LAN-only model breaks.

Quick question on the encryption side: how are you handling key rotation when a user changes their master password? Re-encrypting every row in accounts_encrypt/transactions_encrypt seems expensive — do you wrap a data key with the password-derived key and just re-wrap the data key, or actually re-encrypt the ciphertext?

Collapse
 
doszhan profile image
Doszhan Mengaliyev

Thanks, that’s a fair distinction.

LAN-only sync is a valid model when “same Wi-Fi only” is acceptable.
On key rotation: yes, this is the KEK/DEK approach described in the “Keys” section. The domain data is encrypted with a DEK, and the DEK is wrapped with a KEK derived from the master password.

So when the master password changes, we only re-wrap the DEK with the new KEK. We do not re-encrypt every row.

Collapse
 
privacyfish profile image
Privacy.Fish

The part I like most here is that you call out what stays visible. A lot of E2EE writeups stop at “server can’t read the fields”, but the remaining metadata model is where the real product tradeoffs live: dates, org IDs, row relationships, sync timing, deleted/created patterns, etc.

For finance data, even unencrypted relations can say quite a bit. A burst of rows every payday, recurring vendor-shaped records, or org membership changes may not reveal amounts, but they still leak behavior. Not saying that makes the design wrong — it’s usually the practical split — just that it’s worth documenting as explicitly as the key-recovery downside.

We’re wrestling with a similar shape at privacy.fish for mail: keep more state local, reduce what the provider can retain/read, but be honest that routing metadata and operational logs do not disappear by magic. The hard part is making the privacy boundary understandable without turning every feature into a threat-model lecture.

Collapse
 
superfunicular profile image
Super Funicular

Same observation hits the camera-app side. Even if you encrypt the video files, the upload-burst pattern leaks "someone's home / someone just left" — bursts at 6pm, dead at 9am, that's a routine. Cloud-relay security cameras pretend the privacy story stops at "AES-256 in transit/at rest," but the metadata model (file count per hour, average size, geolocated POP) is a behavior log.

The way Background Camera RemoteStream sidesteps this is by structurally lacking the relay: footage is stored on the phone, viewed over LAN through the device's own embedded web server. There is no upstream traffic shape to analyze because there is no upstream. The cost of that design is exactly the one you're naming — it has to be understandable without a threat-model lecture. Our short form is "if your Wi-Fi is off, the data can't leave the building." Users get that one.

What we still leak: the local recording schedule itself (file-system timestamps on the device, if someone has physical access). And the LAN viewer is trustable-by-WiFi, which is a much weaker assumption on a coffee-shop network than at home — so we say "use it on your own Wi-Fi" out loud in the README, which is the camera-app equivalent of "this is the privacy boundary, drawn explicitly."

Curious what shape you land on for routing metadata at privacy.fish — selective batching to flatten timing signatures, or accept-and-document the leak? We treated it as accept-and-document (file-system timestamps are user-visible anyway), but mail is asymmetric (recipients matter), so the same answer probably doesn't fit.

For anyone landing here from a camera-app angle: play.google.com/store/apps/details...

Collapse
 
privacyfish profile image
Privacy.Fish

Good question. For privacy.fish, I think the honest answer is mostly accept-and-document.

Email has metadata that cannot be wished away: when mail arrives, when a client connects, what recipient domain the server has to deliver to, and the source IP/port records we are legally required to retain. We can reduce retained server-side mail, avoid webmail/tracking surfaces, support Tor/VPN/onion access, and keep storage local-first — but we should not imply that encrypted mail makes routing metadata disappear.

Batching could help a narrow timing-analysis threat model, but it also makes email less useful and still does not hide the social graph from the mail system itself. So I’d rather draw the privacy boundary clearly than sell a cleaner story than email can honestly support.
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
superfunicular profile image
Comment deleted
Thread Thread
 
privacyfish profile image
Privacy.Fish

Mostly it de-risks expectations with technical users, and only partly with everyone else.

The useful effect is that it gives people a sentence to anchor on: “we reduce the provider-side data, but email still has routing metadata.” That stops some overclaiming early, especially with people who already know SMTP. For less technical users, “privacy-first mail” still tends to get heard as “private in every way,” so the boundary has to be repeated in plain-language places: onboarding, docs, support answers, and marketing.

Thread Thread
 
superfunicular profile image
Super Funicular

That four-surface repetition — onboarding, docs, support, marketing — matches what I keep relearning on the camera-app side. "Privacy-first camera" gets heard as "no one can ever see anything," but the residual surface is real: the LAN-broadcast hostname when the local web server is on, app-list fingerprinting if anyone has device access, the SD card itself if the phone is physically taken.

The hardest place to repeat the boundary is the in-app settings screen. That's where users actually make the threat-model decision (turn LAN streaming on or off), and it's also where most apps default to silent toggles with no plain-language consequence text. We're trying a "what this exposes" line under each toggle now (in Background Camera RemoteStream — play.google.com/store/apps/details?id=com.superfunicular.digicam), which feels closer to the docs/onboarding repetition you described but lives at the point of decision instead of at the point of acquisition. Probably not enough on its own, but it stops the toggle-then-forget failure mode.

Collapse
 
kobie profile image
Kobie Botha

Ah nice, the two tables approach 🫡

You might be interested in our High-Performance Diffs, have you checked those out? docs.powersync.com/client-sdks/hig...

Collapse
 
doszhan profile image
Doszhan Mengaliyev

Thanks for the pointer, @kobie ! I really appreciate it.
Looks like this feature shipped right after we built our onChange-based pipeline, so we completely missed it. It seems very relevant to the two-table approach.
Will definitely try it out.

Collapse
 
superfunicular profile image
Super Funicular

Our back-and-forth on Doszhan's E2E thread stuck with me — your 'accept-and-document' take, that structural SMTP leaks (recipient domain, source IP, social-graph at the mail-server layer) can't be wished away, so you'd rather draw the privacy boundary honestly than sell false batching mitigations. I just drafted a piece on the same tension one layer over: when AI agents dispatch real-world tasks to humans, 'proof the work happened' defaults to surveillance, and I think the honest move is minimum legible proof, owned by the worker — basically your framing applied to physical verification. Would love your read, and if it resonates I'd happily fold your angle in or co-sign: The Carbon Layer. Two privacy-first builders comparing notes.

play.google.com/store/apps/details?id=com.superfunicular.digicam