Introducing browser-file-crypto.
We're open-sourcing the client-side file encryption module we built for TimeFile's secure sharing feature.

We're open-sourcing the encryption code from TimeFile's secure sharing feature.
Encrypting files in the browser is more of a hassle than you'd expect. If you use the Web Crypto API directly, you have to generate a salt, create an IV, derive a key... and you end up writing the same 100+ lines of boilerplate every time. TimeFile built an internal module to handle this. We've cleaned it up and open-sourced it, hoping it'll be useful to others.
Installation
1npm install @time-file/browser-file-crypto
1pnpm add @time-file/browser-file-crypto
1yarn add @time-file/browser-file-crypto
Usage
1import { encryptFile, decryptFile } from '@time-file/browser-file-crypto';23// Encrypt4const encrypted = await encryptFile(file, { password: 'secret' });56// Decrypt7const decrypted = await decryptFile(encrypted, { password: 'secret' });
Why we built this
TimeFile has a secure sharing feature. When you upload a file with a password, only the encrypted version is stored on our servers. Only people who know the password can download and decrypt it. To make this work, the file has to be encrypted on the client. The server should never see the plaintext. We first looked at crypto-js, but it's no longer maintained. It also uses Math.random() internally, which isn't secure. We checked out aws-crypto too, but it has Safari compatibility issues and the bundle is over 200KB. That felt like overkill for just encrypting files. So we ended up building our own using the Web Crypto API.
What raw Web Crypto API looks like
For reference, here's what you'd have to write using the Web Crypto API directly:
1const salt = crypto.getRandomValues(new Uint8Array(16));2const iv = crypto.getRandomValues(new Uint8Array(12));34const keyMaterial = await crypto.subtle.importKey(5 "raw",6 new TextEncoder().encode(password),7 "PBKDF2",8 false,9 ["deriveKey"],10);1112const key = await crypto.subtle.deriveKey(13 {14 name: "PBKDF2",15 salt: salt.buffer,16 iterations: 100000,17 hash: "SHA-256",18 },19 keyMaterial,20 { name: "AES-GCM", length: 256 },21 false,22 ["encrypt"],23);2425const ciphertext = await crypto.subtle.encrypt(26 { name: "AES-GCM", iv },27 key,28 data,29);3031// Not done yet - you need to prepend salt and IV for decryption later32const result = new Uint8Array(1 + 16 + 12 + ciphertext.byteLength);33result[0] = 0x01; // marker34result.set(salt, 1);35result.set(iv, 17);36result.set(new Uint8Array(ciphertext), 29);
That's just for encryption. Decryption is another chunk of code. With browser-file-crypto, this becomes two lines.
Features
Password encryption
The most basic usage. Pass a password, and it derives a key using PBKDF2, then encrypts with AES-256-GCM.
1const encrypted = await encryptFile(file, { password: 'my-password' });2const decrypted = await decryptFile(encrypted, { password: 'my-password' });
Keyfile encryption
You can also use a 256-bit random key instead of a password. This is more secure than passwords.
1import { generateKeyFile, encryptFile } from '@time-file/browser-file-crypto';23const keyFile = generateKeyFile();4// keyFile.key contains the base64-encoded 256-bit key56const encrypted = await encryptFile(file, { keyData: keyFile.key });
At TimeFile, we let users download this key as a .tfkey file, then upload it later for decryption.
Progress callback
Want to show a progress bar when encrypting large files? Use onProgress.
1await encryptFile(file, {2 password: "secret",3 onProgress: ({ phase, progress }) => {4 // phase: 'deriving_key' | 'encrypting' | 'decrypting' | 'complete'5 updateProgressBar(progress);6 },7});
Encryption type detection
You can check whether a file was encrypted with a password or a keyfile.
1import { getEncryptionType } from "@time-file/browser-file-crypto";23const type = getEncryptionType(encryptedBlob);4// 'password' | 'keyfile' | 'unknown'
TimeFile uses this to show different decryption UIs - a password input for password-encrypted files, or a keyfile upload button for keyfile-encrypted ones.
Error handling
When the password is wrong or the file is corrupted, you'll get a CryptoError.
1import { CryptoError } from "@time-file/browser-file-crypto";23try {4 await decryptFile(encrypted, { password: "wrong" });5} catch (error) {6 if (error instanceof CryptoError) {7 if (error.code === "INVALID_PASSWORD") {8 alert("Wrong password");9 }10 }11}
Error codes include INVALID_PASSWORD, INVALID_KEYFILE, INVALID_ENCRYPTED_DATA, and more.
Technical details
Encryption spec
- Algorithm: AES-256-GCM
- Key derivation: PBKDF2 (100,000 iterations, SHA-256)
- Salt: 16 bytes (randomly generated each time)
- IV: 12 bytes (randomly generated each time)
AES-GCM is authenticated encryption, so it can detect tampering. If someone modifies the encrypted file, decryption will fail.
File format
Here's what the encrypted file structure looks like:

Bundle size
About 5KB gzipped. It's lightweight because there are no external dependencies - just the Web Crypto API. For comparison, crypto-js is 50KB and aws-crypto is over 200KB.
Browser support
Works in any browser that supports the Web Crypto API. Chrome, Firefox, Safari, Edge - all good. Also works in Node.js 18+ since it has webcrypto built in.
What's next
Right now, the entire file is loaded into memory for encryption. Small files are fine, but multi-GB files might run into browser memory limits. We're planning to add streaming encryption in the next version. We're also considering Web Worker support. Currently encryption runs on the main thread, so the UI can freeze momentarily when processing large files.
Links
- npm: https://www.npmjs.com/package/@time-file/browser-file-crypto - GitHub: https://github.com/Time-File/browser-file-crypto Feedback and bug reports are welcome on GitHub Issues.


