# How to Add File Encryption to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

True file encryption means encrypting in the browser before upload — the server never sees plaintext. Use the Web Crypto API (browser-native, free) with AES-256-GCM, derive the key from the user's password via PBKDF2 (100,000 iterations), store only the salt and IV in your database, and upload the ciphertext blob to a private Supabase Storage bucket. Build time is 4–8 hours with V0; $0–$25/month running cost. The critical rule: if your encryption code runs in a server component, Edge Function, or API route, you do not have client-side encryption.

## What File Encryption Actually Is

File encryption in an app context means the file is scrambled before it ever leaves the user's device — not encrypted in transit (HTTPS handles that), and not encrypted at rest by the storage provider (that protects against cloud provider breaches, not application-level data access). True end-to-end file encryption means your server, your database, your Supabase Storage bucket, and your own admin account see only binary noise. The Web Crypto API (built into every modern browser) provides AES-256-GCM encryption that is industry standard. PBKDF2 derives the AES key from the user's password with a random salt so the key never exists anywhere outside the user's session. The product decisions are what happens when a user forgets their password (all files become permanently inaccessible unless you implement key escrow), and whether files can be shared with other users (requires public key cryptography, significantly more complex).

## Anatomy of the Feature

Six components implement end-to-end file encryption. Three of them involve cryptographic operations that AI tools frequently generate incorrectly — the anatomy section names the exact failure modes.

- **Client-side encryption layer** (backend): crypto.subtle.encrypt('AES-GCM', key, fileArrayBuffer) encrypts the file entirely in the browser. The result is an ArrayBuffer of ciphertext. A unique 12-byte IV (initialization vector) is generated per file using crypto.getRandomValues(new Uint8Array(12)) before every encryption call. The IV is prepended to the ciphertext before upload so it can be separated during decryption.
- **Key derivation via PBKDF2** (backend): crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, passwordKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) derives a 256-bit AES key from the user's password and a random 16-byte salt. The key is not exportable (false in the extractable parameter) and never stored anywhere. The salt is stored in the encrypted_files table and used at decryption time to re-derive the same key from the same password.
- **Encrypted file upload** (backend): After encryption, the ciphertext ArrayBuffer is converted to a Uint8Array, a 12-byte IV is prepended (first 12 bytes = IV, remaining bytes = ciphertext), and the combined Uint8Array is uploaded to a private Supabase Storage bucket as a .enc file using supabase.storage.from('encrypted-files').upload(). Supabase Storage receives only the binary ciphertext — it cannot read the file contents.
- **File metadata table** (data): A Supabase table stores the per-file metadata needed for decryption: original_filename, original_mime_type, salt (base64-encoded), storage_path, and file_size_bytes. The IV is not stored separately because it is prepended to the ciphertext in storage. RLS restricts all access to the file owner only.
- **Decryption download flow** (ui): When the user clicks download: 1) Fetch a signed URL for the .enc file from Supabase (60-second expiry). 2) Download the binary blob. 3) Split the ArrayBuffer into IV (first 12 bytes) and ciphertext (rest). 4) Re-derive the AES key from the user's password + the stored salt via PBKDF2. 5) crypto.subtle.decrypt() the ciphertext. 6) Create a Blob from the plaintext ArrayBuffer with the original MIME type. 7) Use URL.createObjectURL() and a temporary anchor element to trigger a browser download with the original filename.
- **Encrypted storage bucket** (backend): A private Supabase Storage bucket with an RLS policy: only the file owner (matching user_id in the encrypted_files table) can GET or DELETE the object. Signed URLs are generated server-side (Edge Function or API route) using the service role key and expire in 60 seconds to prevent URL sharing from granting permanent access.

## Data model

One table stores file metadata. Run in the Supabase SQL editor (Dashboard → SQL Editor → New Query):

```sql
create table public.encrypted_files (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  original_filename text not null,
  original_mime_type text not null default 'application/octet-stream',
  salt text not null,            -- base64-encoded 16-byte PBKDF2 salt
  storage_path text not null,    -- path in the 'encrypted-files' Supabase Storage bucket
  file_size_bytes bigint,
  uploaded_at timestamptz not null default now()
);

alter table public.encrypted_files enable row level security;

create policy "Users can view own file metadata"
  on public.encrypted_files for select
  using (auth.uid() = user_id);

create policy "Users can insert own file metadata"
  on public.encrypted_files for insert
  with check (auth.uid() = user_id);

create policy "Users can delete own file metadata"
  on public.encrypted_files for delete
  using (auth.uid() = user_id);

-- No UPDATE policy: encrypted files are immutable once written
-- To re-encrypt, delete and re-upload

create index encrypted_files_user_idx
  on public.encrypted_files (user_id, uploaded_at desc);

-- Supabase Storage bucket RLS (set in Dashboard → Storage → Policies):
-- CREATE POLICY "Owner access only"
--   ON storage.objects FOR SELECT
--   USING (bucket_id = 'encrypted-files' AND auth.uid()::text = (storage.foldername(name))[1]);
```

The storage bucket must be created in Supabase Dashboard → Storage → Create Bucket with 'Private' selected. File paths in storage_path should include the user_id as a prefix folder (e.g., '{user_id}/{file_id}.enc') so the Storage RLS policy can restrict access by folder name.

## Build paths

### Lovable — fit 2/10, 5–8 hours

High looping risk. Lovable can scaffold the UI and Supabase Storage hooks, but the Web Crypto API operations require many prompt iterations and careful review. Only for experienced users comfortable debugging crypto code.

1. Create the encrypted_files table and Storage bucket by running the SQL above and creating the bucket manually in Supabase Dashboard
2. Paste the prompt below in Lovable Agent Mode; explicitly verify in generated code that crypto.subtle calls appear inside a useEffect or click handler — never in a server context
3. Click Publish to get an HTTPS URL — crypto.subtle requires a secure context and will silently fail on http://
4. Upload a test file, then log in as a different user and attempt to access the same file via Supabase REST API — confirm it returns a permission error
5. View the Supabase Storage bucket in the Dashboard and confirm the uploaded .enc file is binary noise, not readable content

Starter prompt:

```
Add client-side file encryption to this app using the Web Crypto API. All crypto operations MUST happen in the browser (useEffect or event handler) — NEVER in a Supabase Edge Function, API route, or server component. Encryption flow: 1) User selects a file with a file input. 2) Prompt for an encryption password (use the user's account password via a modal, or a separate encryption password — your choice, but be consistent). 3) Generate a random 16-byte salt: crypto.getRandomValues(new Uint8Array(16)). 4) Derive AES-256-GCM key from password + salt using PBKDF2 with 100,000 iterations and SHA-256: crypto.subtle.importKey then crypto.subtle.deriveKey. 5) Generate a unique 12-byte IV: crypto.getRandomValues(new Uint8Array(12)). 6) Read the file as ArrayBuffer and encrypt with crypto.subtle.encrypt({ name: 'AES-GCM', iv }). 7) Combine IV + ciphertext into one Uint8Array (first 12 bytes = IV). 8) Upload to a private Supabase Storage bucket 'encrypted-files' at path '{user_id}/{uuid}.enc'. 9) Insert row into encrypted_files table with original_filename, original_mime_type, base64-encoded salt, storage_path. Show a lock icon on the uploaded file in the file list. Decryption flow: when user clicks Download — fetch signed URL (60-second expiry via Edge Function), download the .enc blob, split at byte 12 for IV and ciphertext, re-derive key from same password + stored salt, crypto.subtle.decrypt, create Blob with original MIME type, trigger download with original_filename. Error state if decryption fails (wrong password or corrupted file): show 'Decryption failed — check your password'. Test: upload a file, view the bucket in Supabase Dashboard, and confirm it shows binary data, not the original content.
```

Limitations:

- Lovable frequently moves crypto.subtle calls into Edge Functions during complex prompt iterations — carefully review every generated version to confirm encryption stays browser-side
- IV and salt handling (Uint8Array vs ArrayBuffer vs base64 conversions) is the most common failure mode and may require 3–5 iterations to get right
- Looping risk is high: crypto edge cases (wrong ArrayBuffer type, incorrect PBKDF2 parameters) each require separate fix prompts

### V0 — fit 3/10, 4–6 hours

Better than Lovable for this feature — V0 generates cleaner TypeScript for the Web Crypto API flow and Next.js Server Actions for metadata writes. Still requires careful review of the crypto layer.

1. Create the encrypted_files table and private Storage bucket using the SQL above and Supabase Dashboard
2. Prompt V0 with the spec below to generate the EncryptedFileUpload and EncryptedFileList client components and the signed URL API route
3. Add Supabase environment variables in the V0 Vars panel (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY)
4. Deploy to Vercel — crypto.subtle requires HTTPS, which Vercel provides by default
5. After deployment, upload a test file and verify the Supabase bucket shows .enc binary blobs, not the original file

Starter prompt:

```
Build client-side file encryption for a Next.js App Router app using the Web Crypto API. Crypto operations must run only in browser Client Components — never in server components, API routes, or Server Actions. Component: EncryptedFileUpload ('use client'). On file select + password submit: 1) const salt = crypto.getRandomValues(new Uint8Array(16)); 2) const passwordKey = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']); 3) const aesKey = await crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, passwordKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); 4) const iv = crypto.getRandomValues(new Uint8Array(12)); 5) const fileBuffer = await file.arrayBuffer(); 6) const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, fileBuffer); 7) const combined = new Uint8Array(12 + encrypted.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(encrypted), 12); 8) Upload combined to Supabase Storage bucket 'encrypted-files' at '{userId}/{fileId}.enc'; 9) Server Action saveFileMetadata(filename, mimeType, base64Salt, storagePath, size) inserts into encrypted_files table. Component: EncryptedFileList — shows files from encrypted_files table with lock icon and download button. Download handler: 1) GET /api/storage/signed-url?path=... (server route using service role to create 60-second signed URL); 2) download blob; 3) const encData = new Uint8Array(await blob.arrayBuffer()); 4) const iv = encData.slice(0, 12); const ciphertext = encData.slice(12); 5) re-derive key from password + base64-decoded salt; 6) const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); 7) download as Blob with original MIME type. Catch DOMException from decrypt and show 'Decryption failed — incorrect password or file corrupted'.
```

Limitations:

- V0 may generate decryption logic that mixes ArrayBuffer and Uint8Array without explicit conversions — review every type boundary carefully
- V0 does not auto-provision the Supabase Storage bucket as private — verify this manually in the Dashboard before testing
- SSR mismatch is possible if crypto.subtle is referenced at module scope rather than inside useEffect or event handlers

### Custom — fit 5/10, 2–3 weeks

The only path for production-grade end-to-end encryption that passes a security audit. Full implementation with PBKDF2 key derivation, AES-GCM per-file IV, private Storage bucket, RLS, key rotation flow, and download decrypt pipeline.

1. Implement the encryption utility as a dedicated TypeScript module (lib/crypto.ts) with named functions: deriveKey(), encryptFile(), decryptFile() — never inline crypto calls in components
2. Write unit tests for the crypto module covering: successful encrypt/decrypt roundtrip, wrong password failure, empty file, large file (100MB), corrupted ciphertext
3. Implement a key rotation flow: re-encrypt existing files when user changes their password (derive new key, decrypt each file with old key, re-encrypt with new key, update storage_path)
4. Add a key escrow option for enterprise use: encrypt the AES key itself with a recovery public key held by the organization, store the encrypted key escrow blob in the DB
5. Security review before launch: confirm crypto.subtle never appears in server-side code, all bucket objects are private, signed URL expiry is short, and PBKDF2 iterations meet current NIST guidelines

Limitations:

- Key rotation for existing files requires re-downloading, decrypting, re-encrypting, and re-uploading every file — expensive for users with large storage
- Multi-user file sharing requires public key cryptography (Elliptic Curve Diffie-Hellman or RSA-OAEP) to encrypt the AES key per recipient — a significant additional implementation

## Gotchas

- **AI moves encryption to the server side** — This is the most dangerous failure mode and the most common. Lovable and V0 sometimes generate the encryption logic inside a Supabase Edge Function or a Next.js API route — perhaps because the prompt mentioned 'secure upload'. When encryption runs server-side, the plaintext file is transmitted to the server over HTTPS, encrypted there, then stored. This provides no protection against a server breach or an admin viewing files. Fix: Review every line of generated code involving crypto.subtle. It must appear inside a useEffect, a click event handler, or a browser-side utility function — never in files in app/api/, supabase/functions/, or any file without 'use client'. If you see crypto.subtle in a server file, that is a critical defect regardless of what the tests show.
- **ArrayBuffer and Uint8Array confusion silently corrupts files** — Web Crypto API methods return ArrayBuffer, but Supabase Storage upload expects a Blob or Uint8Array. File readers return ArrayBuffer or string. The spread operator [...new Uint8Array(buffer)] works but is extremely slow for large files. Mixing these types — or calling Buffer.from() in a browser context where Buffer is undefined — results in a corrupted upload that decrypts to garbage or throws DOMException. Fix: Use explicit, documented type conversions: new Uint8Array(arrayBuffer) to convert ArrayBuffer to Uint8Array for upload. For combining IV and ciphertext: const combined = new Uint8Array(12 + ciphertext.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(ciphertext), 12). Never use Buffer in browser code.
- **IV not stored or reused across files** — AES-GCM is catastrophically insecure when the same key and IV are used to encrypt two different files — an attacker who has both ciphertexts can recover the plaintext of both. AI tools sometimes generate a single static IV stored as a constant, or store the IV in a way that gets reused when the file metadata row is copied. A reused IV also makes the stored ciphertext manipulable without knowing the key. Fix: Generate a fresh IV for every single file encryption call: crypto.getRandomValues(new Uint8Array(12)). Prepend the IV to the ciphertext before upload so they are always stored together. The encrypted_files table schema above does not store the IV as a separate column for this reason — it is inseparable from its ciphertext in the .enc blob.
- **Supabase Storage bucket left public by default** — Both Lovable and V0 default to public Storage buckets for convenience during initial setup. A public bucket means anyone with the URL of a .enc file can download the ciphertext. While they cannot decrypt it without the key, public access violates defense in depth, exposes file metadata through the URL path, and may violate HIPAA or GDPR requirements for data at rest. Fix: Create the Storage bucket as 'Private' in Supabase Dashboard → Storage → Create New Bucket. Uncheck 'Public Bucket'. Generate signed URLs server-side with a 60-second expiry via an API route using the service role key. Add a Storage RLS policy restricting GET to the file owner based on the user_id prefix in the storage path.
- **Key lost when user forgets their password** — PBKDF2 derives the AES key from the password + salt. The key is never stored. If the user resets their Supabase Auth password (via the email reset flow), their old password is gone — and with it, the only way to derive the key for their previously encrypted files. All existing encrypted files become permanently inaccessible. This is by design for true zero-knowledge encryption, but most founders don't realize it until a user complains. Fix: Choose one of two approaches before launch: 1) Zero-knowledge (no recovery): show a prominent, permanent warning on first encryption that 'If you reset your password, encrypted files cannot be recovered. Save your password somewhere safe.' 2) Key escrow: derive the AES key, encrypt it with a recovery key held by your organization or a KMS, and store the encrypted key escrow blob in the DB. Decision must be made before any files are encrypted — migrating between models requires re-encrypting all files.

## Best practices

- Always confirm crypto.subtle calls appear in browser-only code (client components, event handlers) — never in server files, no matter how convenient it looks
- Generate a unique IV for every single file encryption call using crypto.getRandomValues(new Uint8Array(12)) — reusing IVs with AES-GCM is a catastrophic, silent security failure
- Use 100,000 PBKDF2 iterations at minimum — lower counts make brute-force attacks against the stored salt practical if the database is breached
- Never store the AES key, the password, or any key material in the database — only the salt (for key re-derivation) and the IV (if not prepended to ciphertext)
- Set the Supabase Storage bucket to private and generate short-lived signed URLs (60 seconds) server-side — never expose long-lived or public links to encrypted blobs
- Show a clear, permanent warning at first use that forgetting the encryption password means permanent file loss — make this impossible to miss, not buried in help text
- Test the full encrypt-upload-download-decrypt cycle with at least three file types (PDF, image, zip) and confirm decryption fails gracefully with a user-friendly message on wrong password
- Verify the encryption is actually working by viewing the raw .enc file in Supabase Storage and confirming it shows binary noise, not recognizable file headers

## Frequently asked questions

### Does encrypting files affect upload speed?

Minimally for files under 50MB. AES-GCM encryption of a 10MB file takes 50–150ms in modern browsers — imperceptible to users. Files over 100MB may take 1–3 seconds to encrypt before upload begins, which is worth showing a progress indicator for. PBKDF2 key derivation with 100,000 iterations takes 200–500ms and happens once per session (or per upload if you re-derive per file).

### What happens to my files if I forget my password?

With true client-side encryption using PBKDF2, the AES key is derived from your password. If you forget your password and reset it via email, your old password is gone — and with it, the ability to re-derive the key. All previously encrypted files become permanently inaccessible. This is the correct behavior for zero-knowledge encryption. If this is unacceptable for your users, you need to implement key escrow: encrypt the AES key with a recovery key and store the encrypted copy separately.

### Can admins read encrypted files?

No — that is the point of client-side encryption. Admins see only the .enc binary blob in Supabase Storage. Without the user's password (which is never stored), there is no way to derive the AES key and decrypt the file. This is different from 'storage encryption at rest', where the cloud provider encrypts data but can still access it on behalf of the app operator.

### Is AES-256 encryption safe enough for sensitive documents?

AES-256-GCM is the industry standard for symmetric encryption and is approved by NIST for protecting classified information up to TOP SECRET. The algorithm itself is not the risk — the implementation details are. A correct AES-256-GCM implementation with unique IVs and proper PBKDF2 key derivation is secure. The failures come from: reused IVs, weak PBKDF2 iteration counts, or encryption happening server-side instead of client-side.

### Does file encryption affect search and preview features?

Yes, fundamentally. Encrypted files are binary blobs — your server cannot index their text content for search, generate thumbnails, or display previews without the decryption key. If you need full-text search of encrypted documents, you must decrypt the file client-side before display and never store plaintext server-side. This makes encrypted files incompatible with server-side search, PDF preview, or image thumbnail features.

### What's the difference between encryption at rest and end-to-end encryption?

Encryption at rest (like Supabase Storage's default) means the cloud provider encrypts the file on their disk. They hold the keys, so they (or someone with access to their systems) can decrypt it. End-to-end encryption means only the intended recipient (the user) holds the key. Your server never has the key and cannot decrypt the file regardless of who asks. The Web Crypto API approach on this page is end-to-end encryption.

### Can I share encrypted files with other users?

Not with symmetric encryption alone. If the AES key is derived from User A's password, User B cannot decrypt it without knowing User A's password. Sharing requires asymmetric cryptography: generate a key pair for each user, encrypt the file's AES key with the recipient's public key (using ECDH or RSA-OAEP), and store that encrypted copy. The recipient uses their private key to recover the AES key and decrypt the file. This is significantly more complex to implement correctly.

### How do I know my files are actually encrypted?

Upload an encrypted file and then view it directly in the Supabase Dashboard → Storage → your bucket. The raw bytes should look like random binary garbage, not a recognizable file header. For a JPEG, you should NOT see 'JFIF' or 'Exif' in the first few bytes. For a PDF, you should NOT see '%PDF-'. If you can see recognizable file content in the raw Storage view, your encryption is not happening before upload.

---

Source: https://www.rapidevelopers.com/app-features/file-encryption
© RapidDev — https://www.rapidevelopers.com/app-features/file-encryption
