Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 5 min read

Building browser-encrypted file sharing on Cloudflare

Temporary file sharing looks simple until you define the trust boundary. If the application server receives both the encrypted object and its decryption key, encryption at rest is useful operational hygiene, but the serv

Temporary file sharing looks simple until you define the trust boundary. If the application server receives both the encrypted object and its decryption key, encryption at rest is useful operational hygiene, but the server can still decrypt the transfer.

For a file-transfer project I built, I chose a narrower boundary: the browser encrypts before upload, the storage backend receives ciphertext, and the transfer key is never written to the application database.

This article describes the resulting design, the Web Crypto details that matter, and the limitations that remain.

Architecture

The implementation uses Cloudflare Pages for the web interface, Workers for the API, D1 for transfer metadata, and a private R2 bucket for encrypted objects.

Sender browser
  β”œβ”€ generates a fresh AES-256-GCM key
  β”œβ”€ encrypts the filename and file chunks
  β”œβ”€ requests short-lived upload URLs from the Worker
  └─ uploads ciphertext directly to private R2
                         β”‚
                         β–Ό
Worker + D1 ── stores metadata, quota and expiration state
                         β”‚
                         β–Ό
Recipient browser
  β”œβ”€ receives the key in the URL fragment
  β”œβ”€ downloads encrypted chunks
  β”œβ”€ authenticates and decrypts each chunk
  └─ reconstructs the local file

Large encrypted bodies do not pass through the Worker. The API creates a multipart session and returns short-lived signed URLs, while the browser uploads each encrypted part directly to R2. That keeps the Worker focused on authorization, quotas, lifecycle state, and metadata.

Generating and transporting the key

The sender generates a new extractable AES-GCM key for each transfer:

const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"]
);

const rawKey = new Uint8Array(
  await crypto.subtle.exportKey("raw", key)
);

The raw key is encoded with base64url and placed after # in the generated share URL:

https://example.com/t/transfer-token#base64url-key

During normal navigation, the URL fragment is handled by the browser and is not included in the HTTP request. The server receives the transfer token but not the fragment containing the key.

This is a useful transport boundary, not magic secrecy. The complete link still contains everything needed to decrypt the transfer. It can leak through screenshots, clipboard history, browser extensions, messaging systems, or a compromised endpoint. Application analytics and error reporting must also be designed never to collect complete URLs or fragments.

Chunked authenticated encryption

AES-GCM provides confidentiality and authentication. Every filename and chunk receives a fresh random 96-bit IV. Reusing an IV with the same GCM key can break the security of the construction, so the IV is generated independently for every encryption operation.

function aadForPart(fileId, partNumber) {
  return new TextEncoder().encode(
    `TRANSFER-V1|${fileId}|${partNumber}`
  );
}

async function encryptChunk(buffer, key, fileId, partNumber) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = new Uint8Array(
    await crypto.subtle.encrypt(
      {
        name: "AES-GCM",
        iv,
        additionalData: aadForPart(fileId, partNumber)
      },
      key,
      buffer
    )
  );

  const stored = new Uint8Array(iv.length + ciphertext.length);
  stored.set(iv, 0);
  stored.set(ciphertext, iv.length);
  return stored;
}

Additional authenticated data binds a ciphertext chunk to its file identifier and part number. That prevents a valid encrypted part from being silently moved to another position or file within the same protocol.

The stored representation is:

12-byte IV || ciphertext || 16-byte GCM authentication tag

The encrypted filename uses the same construction with a separate protocol label in its authenticated data.

Direct multipart upload

For each encrypted part, the browser:

  1. Reads a bounded slice from the local File object.
  2. Encrypts that slice in memory.
  3. Requests a signed URL for the expected part number and encrypted size.
  4. Uploads the encrypted bytes directly to R2.
  5. Returns the R2 ETag to the API so the part can be confirmed.

The API validates the expected encrypted size before signing and again when confirming. Upload sessions are abortable, and abandoned reservations must release quota. A retry checks whether the part was already confirmed before uploading it again.

This separation is valuable, but it adds state transitions that require testing: initialized, uploading, completed, aborted, expired, and consumed.

Passwords are not encryption keys

An optional transfer password is an authorization control, not the file-encryption key. The random AES key stays in the fragment. The backend stores a salted password verifier and checks it before authorizing encrypted-part downloads.

Keeping these concepts separate avoids deriving the content key from a potentially weak human password. It also makes the user-facing warning clearer: anyone with the complete active link and any required password may be able to retrieve the file.

Expiration and one-time download

Temporary storage still needs explicit lifecycle rules. Transfers have a server-enforced expiry, and a sender can request deletion after the first successfully finalized download.

β€œDelete after download” should not be triggered when the first byte is requested. Browsers can cancel, retry, or fail during local save. The recipient reports successful finalization only after all chunks have been authenticated, decrypted, reconstructed, and handed to the save path. The server then marks the transfer consumed and schedules object deletion.

This is best described as deletion after the first completed download, not as a guarantee that only one copy can ever exist.

What the backend still knows

Browser-side encryption does not eliminate metadata. The service still needs limited operational information such as:

  • encrypted object sizes and part counts;
  • creation and expiry timestamps;
  • transfer state and quota reservations;
  • account and billing records when an account is used;
  • coarse security and abuse signals.

The practical goal is data minimization, not pretending that metadata does not exist. In this implementation, growth analytics accept only aggregate event dimensions. Filenames, transfer identifiers, share URLs, fragments, keys, recipients, and file contents are rejected from the analytics payload.

Failure modes worth testing

The highest-value tests were not visual snapshots. They exercised boundaries:

  • upload, download, and SHA-256 equality across web and native clients;
  • tampered ciphertext, wrong part numbers, and failed GCM authentication;
  • refresh and resume during multipart upload;
  • cancellation followed by quota release;
  • expiry and delete-after-completed-download behavior;
  • missing fragments, incomplete links, and incorrect passwords;
  • Safari and mobile save fallbacks;
  • analytics and logs that accidentally include full URLs.

One small but important regression test asserts that the cryptographic primitives and transfer handlers remain byte-for-byte unchanged when unrelated marketing or UI work is deployed.

What this model does not solve

This design cannot protect a file after the recipient saves it. It cannot protect against a compromised sender or recipient browser, malicious extensions, endpoint malware, or a complete-link leak. It also does not replace independent security review.

The useful property is narrower: the ordinary storage and metadata path does not need the transfer decryption key. That reduces the number of backend systems that must be trusted with plaintext, while keeping the workflow usable in a normal browser.

I applied this architecture in PsiDrive. The most difficult engineering work was not calling crypto.subtle.encrypt; it was designing the lifecycle around retries, cancellation, expiration, local saving, and honest user-facing language.

Which boundary would you test first in a browser-encrypted transfer system?

AI disclosure: I used an AI coding assistant to help structure and edit this article. I reviewed the technical claims against the implemented code and remain responsible for the content.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.