Skip to content
HexaTransfer
Back to blog
Technical Deep Dives

Build an Encrypted File Sharing App from Scratch

Step-by-step tutorial to build an encrypted file sharing application. Frontend encryption, secure backend, and deployment walkthrough.

Building an encrypted file sharing app means putting the crypto in the browser, not the server. A minimal stack looks like this: a Vite-powered React frontend that encrypts with AES-256-GCM via the Web Crypto API, a Fastify backend that treats every upload as an opaque blob, S3-compatible storage (Cloudflare R2 or Backblaze B2), and a short share URL where the decryption key lives after the # so it never reaches the server. You can ship a working version in about 400 lines of code and host it for under $5 a month.

Architecture Decisions That Actually Matter

The single most important decision is where the encryption key lives. If it ever touches your server, you don't have end-to-end encryption, you have server-side encryption with extra steps. The right pattern: generate a random 256-bit key in the browser, encrypt the file with it, upload the ciphertext, and put the key in the URL fragment (https://yourapp.com/f/abc123#k=base64key). Browsers never send fragments in HTTP requests, so the key stays client-side.

A second call: chunked uploads. For files above 100 MB you need resumable multipart uploads, or any flaky Wi-Fi kills the transfer. S3's multipart API supports 5 MB minimum chunks and up to 10,000 parts, giving you a 50 GB ceiling. Plan for it from day one.

Setting Up the Project Skeleton

Start with two packages: a Vite + React frontend and a Fastify backend. The frontend handles all crypto, the backend handles storage and metadata. Use TypeScript so you get type safety on ArrayBuffer and CryptoKey.

pnpm create vite@latest frontend -- --template react-ts
pnpm create fastify backend

Add these deps to the backend: @fastify/multipart, @aws-sdk/client-s3, @aws-sdk/s3-request-presigner, and better-sqlite3 for share metadata. Keep your SQLite schema tiny: shares(id, object_key, size_bytes, expires_at, download_count, max_downloads). No filenames, no user data, no IPs.

Writing the Frontend Encryption Pipeline

Generate a key, encrypt with AES-256-GCM, and produce a ciphertext blob plus a base64 key for the URL fragment:

async function encryptFile(file: File) {
  const key = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
  );
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = await file.arrayBuffer();
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, plaintext
  );
  const rawKey = await crypto.subtle.exportKey('raw', key);
  const blob = new Blob([iv, new Uint8Array(ciphertext)]);
  return { blob, keyBase64: toBase64(rawKey) };
}

For files over 50 MB, replace this with a streaming version that encrypts 4 MB chunks and appends them to a ReadableStream. The mobile Safari heap chokes on anything larger than about 400 MB in a single ArrayBuffer.

Designing the Upload Endpoint

The backend should know nothing useful. Accept a POST with the ciphertext, generate a random 16-character URL-safe ID, store it in SQLite with expiry, and stream the body straight to S3:

fastify.post('/upload', async (req, reply) => {
  const id = nanoid(16);
  const key = `blobs/${id}`;
  const upload = new Upload({
    client: s3,
    params: { Bucket: 'hexa-transfers', Key: key, Body: req.raw }
  });
  await upload.done();
  db.prepare('INSERT INTO shares VALUES (?, ?, ?, ?, 0, ?)').run(
    id, key, req.headers['content-length'], Date.now() + 7*86400*1000, 10
  );
  return { id };
});

Default expiry at 7 days, max 10 downloads. You want aggressive defaults because the alternative is unbounded storage growth. Use a cron to delete expired blobs nightly.

Generating Share Links With Fragment Keys

Once upload finishes, build the share URL client-side:

const { id } = await uploadResponse.json();
const shareUrl = `${location.origin}/f/${id}#k=${keyBase64}`;

That fragment never leaves the user's browser. When a recipient clicks the link, your React app reads window.location.hash, parses the key, fetches the ciphertext, and decrypts locally. The server has no path to the plaintext short of pushing malicious JavaScript to the user — which is exactly why you should ship a tight Content Security Policy and pin subresource hashes.

Implementing the Download Flow

On the download page, pull the ciphertext as a stream and decrypt chunk-aligned:

const keyRaw = base64ToBytes(location.hash.slice(3));
const key = await crypto.subtle.importKey(
  'raw', keyRaw, { name: 'AES-GCM' }, false, ['decrypt']
);
const resp = await fetch(`/blob/${id}`);
const iv = new Uint8Array(await resp.body.getReader().read().then(r => r.value.slice(0, 12)));
const ct = await resp.arrayBuffer();
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct.slice(12));
const url = URL.createObjectURL(new Blob([pt]));

For big files, use StreamSaver.js or the File System Access API so the decrypted bytes hit disk directly instead of RAM.

Deploying the Whole Thing

Frontend: push to Cloudflare Pages or Vercel, both free tiers handle this. Backend: a single $5 DigitalOcean droplet running Node 22 behind Caddy (automatic TLS 1.3). Storage: Cloudflare R2 gives you zero egress fees, which is the killer feature for file sharing at scale. WeTransfer, Smash, and SwissTransfer all burn a fortune on AWS egress; R2 changes the economics completely.

Set strict headers via Caddy: Strict-Transport-Security, Content-Security-Policy: default-src 'self'; script-src 'self', X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer. These close down the common vectors for leaking the URL fragment through referers or injected scripts.

Making the App GDPR-Friendly by Default

Because the server only ever sees ciphertext, you have almost nothing that qualifies as personal data under GDPR Article 4. Still, document your processor relationships (R2 and DigitalOcean), set a 30-day blob retention max, and publish a clear notice that share links contain keys and should be transmitted over channels the sender trusts. Add rate limiting at 50 uploads per IP per hour to discourage abuse without logging user identities.

HexaTransfer is built on essentially this architecture — Web Crypto in the browser, opaque blob storage, keys in URL fragments, zero plaintext on servers. Try it at hexatransfer.com — free, no account, 10 GB max.

What to Add Once the MVP Works

Once the basic flow ships, the highest-value additions are: password protection on top of the fragment key (PBKDF2 with 600,000 iterations), per-download email notifications via a burner inbox, and abuse reporting that lets the hash of the ciphertext be flagged without revealing the content. Avoid adding accounts unless you have a specific reason — they turn your app from a privacy tool into a data liability overnight. Ship small, stay opinionated, and let the crypto do the heavy lifting.

Send large files securely with end-to-end encryption

Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.

Send a file