Why default encryption wasn't enough
Every object in Cloudflare R2 is encrypted at rest by default, with keys Cloudflare manages. That answers "what if someone steals a disk". It doesn't answer the question an accountant actually asks: if one credential, one bug or one misconfigured bucket exposed our storage, what would someone be able to read?
With default encryption, the answer is everything. We wanted a copy of the bucket alone, or a copy of the database alone, to reveal nothing, and each accounting firm's files to be locked under keys that belong to that firm. We also didn't want to run a key management service or store millions of file keys.
SSE-C in one paragraph
R2 supports server-side encryption with customer-provided keys (SSE-C). You send a 256-bit key with each write; R2 encrypts the object with it and keeps only a hash to check later requests. To read the object you send the same key again. Without it, R2 refuses. In a Worker it is one option on the binding:
await env.FILES.put(r2Key, body, { ssecKey });
const object = await env.FILES.get(r2Key, { ssecKey });
We checked the behavior against a real bucket before building on it. A read with no key fails with (10034) The object was stored using a form of Server Side Encryption. A read with another key fails with (10003) Access Denied. Range reads, multipart uploads and HEAD (size and content type, no key needed) all work.
Where the keys come from
Each firm already had a 256-bit data key, generated when the firm signs up and stored wrapped (AES-256-GCM) by a master key that lives in the Worker's secrets, not in the database. We use it for the most sensitive fields, such as Social Security numbers.
A file's key is derived from that data key and the object's path with HKDF-SHA256, at the moment the file is read or written:
export async function deriveObjectKey(masterKey, wrappedDataKey, objectKey) {
const dek = await unwrap(masterKey, wrappedDataKey); // the firm's 32-byte key
const base = await crypto.subtle.importKey("raw", dek, "HKDF", false, ["deriveBits"]);
return crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: new TextEncoder().encode("usual/r2-object/v1"),
info: new TextEncoder().encode(objectKey),
},
base,
256,
);
}
So every object has its own key, two firms never share one, and nothing new is stored: the object's path is the only input that differs. Deleting a firm's data key makes every one of its files unreadable, including any copy we failed to delete.
Every read and write
The hard part wasn't the cryptography; it was finding every place a file is touched. In our case: single uploads, multipart uploads (each part gets the key; complete() doesn't need it), staff uploads, previews, two-minute download links, ZIP downloads of a request, the full-account export, the virus scanner, and the copy to a firm's Google Drive or OneDrive, which reads the file in 10 MB ranges. Brand logos stay unencrypted because they are public on purpose.
Each of those paths now asks one helper for its options. Rows carry a sealed flag, so the same code reads both new files and files stored before the change:
export async function sealOptions(tx, crypto, firmId, file) {
if (!file.sealed) return {};
const keyFor = await sealer(tx, crypto, firmId); // loads the firm's wrapped key once
return { ssecKey: await keyFor(file.r2Key) };
}
The local emulator ignores it
This is the part worth writing down. Wrangler's local R2 (Miniflare) accepts ssecKey and silently ignores it. Our end-to-end tests (uploads that resume after a reload, 160 MB files, previews, ZIP downloads) all passed locally while nothing at all was being encrypted, and they would have kept passing if one path forgot the key.
So we verified it twice against real R2: once with a throwaway Worker and bucket that tries the right key, the wrong key and no key, and once with our production smoke test, which signs up a new firm, uploads in parts, waits for the real virus scanner and checks that the downloaded ZIP is byte-for-byte what went up. The scanner is the useful canary here: it can only read the file if the key path is right.
Rewriting the files already stored
Files uploaded before the change were marked sealed = false by the migration, and the column's default flipped to true for everything after it. A job then rewrites them a batch at a time: read the object without a key, stream it back to the same path with its key, mark the row sealed. Streaming goes through a FixedLengthStream so R2 knows the length without buffering a 1 GB file in memory.
There is a window of milliseconds between the write and the row update where a reader would still try without a key and fail. For a background job over a few files at a time, a retry covers it.
What this does and doesn't protect
SSE-C is still server-side encryption: during a request, R2 holds the key and sees the plaintext, and our Worker can derive any firm's keys because it holds the master key. This is not end-to-end encryption, and we don't describe it as that. What it does change:
- A leaked storage credential or a copy of the bucket reveals no documents.
- A copy of the database reveals no documents either, and no usable keys: the firm keys in it are wrapped by a master key that isn't there.
- One firm's key opens nothing of another firm's.
- Deleting a firm's key is a reliable last step when an account is closed.
It sits next to the rest of what we do for client documents (row-level security per firm in Postgres, virus scanning that fails closed, no documents in email), which is written up on our security page.
Usual is document collection for accounting firms: send a checklist, the client finishes it on their phone, reminders send themselves. See how it works.