Claude handled the cryptography fine, the easy part. The privacy promise nearly broke on three sharp edges around it that only a careful review caught.
I have built several privacy-first apps, which means I sometimes want a feature where my own servers genuinely cannot read what a user stored or shared. That is a strong promise, and it is worth making carefully, because a privacy claim that is true on Tuesday and false on Wednesday is worse than no claim at all. Claude implemented these features, and the cryptography itself, the part everyone worries about, was the well-trodden, easy part. The places the promise nearly slipped were all around the edges, and those edges are exactly what review is for. Here are three.
A secret in a URL is server-blind, until your server mails the link
The feature was a no-account web recipient opening a share link and decrypting it in their browser, without my backend ever seeing the key. The trick is real and elegant: put the key in the URL fragment, the part after the #. By the URL specification, the fragment is never sent to the server. It does not appear in the request, it does not land in access logs, and modern browsers strip it from the referrer header. The page loads without it, and client-side JavaScript reads it locally to decrypt. A key in the query string, the part after the ?, is the opposite. That one is sent to the server and written to logs, so a secret never belongs there.
So far so good. Here is the edge the implementation walked right past. The fragment stays off the server only while the user distributes the link themselves, through their own messages or email or share sheet. The moment a new feature is added and my backend is the thing sending the link, by server-sent email or SMS, the backend has to handle the full URL including the fragment. At that instant the server can see the key and the server-blind property collapses for that channel.
The practical rule I came away with is to audit every distribution channel separately and to match your public claim to the weakest one. For a user’s own backup, where they hold the key across their own devices, true end-to-end encryption is honest. For cross-user sharing that the server might deliver, I do not claim end-to-end. I say encrypted in transit and at rest, keep the data short-lived, and never log full URLs. An honest smaller claim beats an impressive claim with an asterisk.
The crypto API that vanishes off HTTPS
The recipient page worked on my laptop and failed on a real phone with a generic “this link looks corrupted” message. The easy assumption was a bug in the freshly written encryption code. It was not. When I pulled the actual stored data off the server and decrypted it in Node with the exact key from the link, it worked perfectly. The data was fine. Only the browser environment was failing.
The cause is that the browser’s crypto.subtle, the part that does the actual AES decryption, is only available in a secure context. Secure contexts are HTTPS, plus the loopback exceptions of localhost and 127.0.0.1. A plain http:// page served over a LAN IP, which is exactly how you reach a dev server from a physical phone, is not a secure context, so crypto.subtle is simply undefined there. The call threw, the page’s catch-all swallowed it, and it surfaced as a misleading “corrupted” message.
There is a nasty trap layered on top. The random-number part of the crypto API still works in an insecure context, so the half of the code that generates keys runs fine and nothing complains until the decryption call. And it works on localhost, so it only breaks once you test on a real device. The fix is to serve the dev page from a secure origin, which a quick HTTPS tunnel handles for free, and to check explicitly for a missing crypto.subtle so you show an honest “open this over HTTPS” message instead of a generic failure.
Two libraries, one key, only if every parameter agrees
The last edge is about deriving the same key in two different runtimes. The sharer runs in React Native and uses one Argon2id library, and the recipient runs in a browser and uses a different one. If their outputs differ by a single byte, the recipient simply cannot decrypt, with no useful error to explain why.
The good news is that they do match byte for byte. The catch is that they match only when every single parameter is pinned explicitly: the memory size and its unit, the iteration count, the parallelism, the output length, the algorithm version, and the exact byte encoding and normalization of the input. The two libraries expose different defaults and different API shapes, so “the same call” in each has to be spelled out deliberately. One is synchronous and returns bytes, the other is asynchronous and returns hex unless you ask for bytes.
What saved future me was treating these parameters as a wire contract rather than configuration. They are defined once in a shared module both sides import, with a frozen test vector, a known input and its known output, checked in CI. If either library changes a default in a future version, or someone fumbles the input normalization, the test fails loudly in the pipeline instead of silently in a user’s browser.
The pattern under all three
Every one of these has the same shape. The cryptographic primitive worked exactly as designed. The failure lived in the environment around it: who delivers the link, what context the browser is in, which defaults a library ships. If you are building something genuinely private, spend your suspicion on the edges, prove which layer is actually failing before you rewrite the one you suspect, and make your public promise only as strong as the weakest channel can keep. The code was the easy half to generate. Deciding where the promise could quietly break was the half that needed a human.