Engineering · August 25, 2026
When Your Backup Is the Leak
If you build a privacy or end-to-end-encrypted tool on managed cloud, you will spend a week on the cryptography and then discover the cryptography was never the hard part. Choosing an AEAD, wrapping per-item keys, and gating a root key behind a passphrase are all well-trodden ground. The hard part is that the platform underneath you is engineered, by default, to remember, and a system tuned for durability and observability is a system tuned against forgetting. What follows walks through where that bites, using Cloudflare's stack by name, because the specifics carry the lesson even though the principle is general.
Start with a genuinely good feature
Cloudflare D1 ships a feature called Time Travel. It is backed by the database's Write-Ahead Log, and it lets you restore to any minute within the last 30 days on Workers Paid, 7 days on Free. It is on by default and there is no opt-out. For the job it was built for, restoring a table to the way it stood at 1:59 after you ran the wrong migration at 2am, it is exactly the right tool, and it will save you.
Now read the fine print the way an adversary would. Time Travel operates only at the whole-database level: a restore is a destructive operation that overwrites the database in place, and there is no documented mechanism to expunge a single row or value from that history before the window expires. Put those two facts together and the consequence is sharp: every DELETE and every UPDATE you run against D1 is a logical delete. The pre-change bytes, the old rows, the old ciphertext pointers, the old wrapped keys, and the old timestamps, stay reconstructable for up to 30 days, no matter what your application code believes it destroyed, and you cannot shorten that window.
How that quietly defeats "we deleted it"
Say you have a /burn endpoint. The user triggers it, your Worker runs a DELETE on their row, and you return destroyed. For up to 30 days, wrangler d1 time-travel restore --timestamp=<a minute before the burn> reconstructs that whole row: the wrapped key, the ciphertext pointer, the metadata. Your destruction claim is false for the entire retention window, and nothing in your code can make it true sooner.
Crypto-shredding has the same hole, and it is worth being precise because crypto-shred is the move most of us reach for. The idea is sound: rather than chase the data, you delete the one key that unwraps it, and the ciphertext goes inert everywhere at once. If that wrapping key ever lived in D1, though, its prior value is restorable for 30 days. Anyone who can run a point-in-time restore, or who simply kept a copy of the ciphertext, can still open it. The shred removed a row while leaving the key itself intact.
If your keys live in a Time Travel database, a point-in-time-recovery database, or a versioned bucket, then "shred" means unrecoverable in about a month rather than genuinely gone. That is a defensible thing to ship, but only if you say the number out loud. If you cannot say it, the keys belong somewhere else.
The pattern: every convenience is a potential oracle
Time Travel is one instance of a general shape, and once you see it you see it everywhere in the stack. None of these are bugs; each is a feature that someone sensibly wants, and each also retains or exposes exactly what your crypto was meant to hide.
Backups and point-in-time recovery. The case above covers these directly: the purpose of a backup is to defeat deletion, which becomes a problem precisely when deletion is your privacy guarantee.
Object versioning. On S3 or GCS with versioning enabled, a DELETE only writes a delete-marker and an overwrite only creates a new current version. The old bytes persist as noncurrent versions until you explicitly delete each versionId or set a NoncurrentVersionExpiration lifecycle rule. A "deleted" object can carry several still-readable prior copies that never show up in a normal listing. The same holds on R2, which now implements object versioning: a single bucket-level toggle, once on, silently retains pre-delete copies and delete-markers, which is why versioning has to stay explicitly off on any bucket that holds keys or ciphertext you must be able to shred. Versioning is only one door, because the same leak also arrives through copy pipelines you may have switched on: an event notification mirroring writes to a second bucket, a Super Slurper or replication job, and an external backup cron each reintroduce a surviving copy your delete never reaches.
Retention locks and WORM. R2 Bucket Locks prevent the deletion and overwriting of objects for a fixed duration, until a date, or indefinitely. When rules overlap, the strictest, longest retention wins, and a bucket cannot be emptied while any lock rule is configured. That immutability is the entire point of the feature, designed so it cannot be shortened or bypassed. Enabling a lock "for safety" on a bucket that also holds user vault blobs therefore builds a hard conflict into your product: you physically cannot honor a GDPR erasure or a burn for objects inside the retention window, and an indefinite lock makes that data permanently un-erasable. For that reason, retention lock and erasure must never share a bucket.
Edge IP and logs. The real client IP arrives at the edge in the CF-Connecting-IP header, a single accepted value exposed to a Worker through request headers and request.cf. On its own it is transient, processed in flight and never stored. Enable a Logpush job on the HTTP-requests dataset with the ClientIP field, though, and every request's IP lands, timestamped, in a durable sink: R2, BigQuery, Splunk, Datadog, often under a retention policy you do not control. A single stray console.log(request.headers.get('CF-Connecting-IP')) left in a Worker does the same thing through Workers Logs. A "no-logs" page is silently false the moment anyone flips that switch or forgets that line.
Metadata columns. Even with every body encrypted under AEAD, the rows around it leak a behavioral graph. Exact ciphertext byte sizes are a content-length signal, and across versions they become an edit-magnitude signal. created_at and updated_at reveal when and how often someone is active. An edit-count or version column reveals how many times a thing changed. Per-user row counts reveal how much they hold, and session rows reveal device count, login times, last-seen, and IPs if you stored them. A dump of that table, whether by court order or by breach, is who-did-what-when with zero plaintext. Because Time Travel captures the metadata table too, "we cleaned that up" is not true for another 30 days.
Designing against your own stack
The fix keeps managed infrastructure in place, auditing each subsystem against the guarantee before you ship and arranging your data so the durable defaults never touch the material that has to stay forgettable. These are the rules we design to:
Put key material where the platform cannot retain it. Wrapped-key blobs belong in a store you can actually purge, with versioning off and object-lock off, on an aggressively short lifecycle, kept separate from the bulk ciphertext and out of any Time Travel database, PITR database, or versioned bucket. This one rule is what makes crypto-shred honest. We follow it ourselves: Privt keeps its wrapped-root blobs in a dedicated R2 bucket with versioning off, never in a Time Travel or PITR database, and burn deletes those key objects as its first and final step, before it touches anything else. What we do not round up to done is the humbler tier: purging D1 point-in-time-recovery and R2 version retention for the bulk item-ciphertext, so a shred reaches those backups too. That boundary is written down in the whitepaper and sits on the roadmap.
Delete the keys first. Sequence a destroy so a single atomic write kills the decryption material, meaning the wrapped root keys, the KDF salts and parameters, the verifiers, and the destroy-credential hash, before it touches sessions, ciphertext, or bookkeeping. Order it to fail toward destruction, so that a partial failure still leaves the content unopenable, because the only thing that could open it is already gone.
Never enable versioning or object-lock on the ciphertext or key buckets. One retained prior version of a wrapped-root blob silently defeats burn, which is why versioning, object-lock, and PITR are the direct enemies of crypto-shred. Keep them off those buckets by policy, and plan to purge any retention tier a shred does not natively reach, including the still-unlocked device that holds an unwrapped key in memory. A server-side burn that never reaches the client falls short of a full shred.
Pad ciphertext to size buckets. Storing exact byte sizes leaks plaintext length, and version-over-version it leaks edit magnitude. Round every stored blob up to a fixed set of sizes so the shape of the box stops describing its contents. Encrypting the payload does nothing about the dimensions you wrote next to it.
Make timestamps coarse but still monotonic. Last-write-wins sync needs an ordering, but a wall-clock updated_at broadcasts edit frequency and activity patterns. Use day-granularity timestamps plus a per-account monotonic counter: LWW still resolves conflicts in the right order, while the fine timing no longer leaks. Drop the edit-count column for the same reason.
Keep IP logging off by default and by policy. A court order reaches what you stored; a pen-register reaches only what you capture going forward. What you never wrote down, you cannot be compelled to produce from the past. So keep IP and user-agent logging hard-off, keep rate-limit state as ephemeral counters rather than an audit trail, do not record refused attempts, and add no last-active column. Better still, front the origin with a relay or an OHTTP hop so the edge never sees the source IP to begin with, and make sure object and request paths do not encode the account id, so that if logs are ever switched on they cannot retroactively deanonymize.
The honest floor
The reason to write all of this down is that a privacy claim is only as strong as the least forgetful part of your stack. "We encrypt the content" is table stakes, and it is the easy half. The hard half is enumerating, field by field and subsystem by subsystem, exactly what the platform still remembers after your code has said "deleted," and then either fixing it or stating it plainly. We benchmark that floor field-for-field against Signal's, and we flag the mitigations that are designed but not yet deployed, among them the padding, the coarsened timestamps, the relay, and the retention purge on burn, rather than describing them as if they shipped. An unshipped mitigation provides no guarantee, and a good tool aimed at the wrong data leaves the exposure in place, because the problem there is architectural.
The version of this you can actually trust rests on architecture rather than a louder promise: the durable, un-forgettable parts of the platform never hold anything that has to disappear. That gives you something you can audit rather than something you have to take on faith.