Works with any backend npm install Zero dependencies

Zero-knowledge upload via AES-GCM-256 + PBKDF2

Every queued file is encrypted in the browser with AES-GCM-256 before upload begins; your backend only ever sees ciphertext. Decryption requires the password and the per-file salt + IV emitted as X-Mu-Encryption-* headers. Compliance-friendly default for HIPAA / GDPR confidentiality workflows. None of Uppy, FilePond, Dropzone, or FineUploader offer this built-in.

The password never leaves the browser. Server only sees ciphertext + the salt & IV needed to decrypt later.

Try it: drop a file, hit upload, inspect the request body in DevTools -> Network. Payload is unintelligible binary; six headers carry the metadata needed to decrypt later.
Configuration
new MultipleUpload('#uploader', {
 uploadUrl: '/api/upload',
 encrypt: true, // or 'aes-gcm-256'
 encryptPassword: 'shhh',
 // OR for a prompt-driven UI:
 // encryptPassword: () => promptUser(),
 // OR for a pre-derived CryptoKey:
 // encryptKey: cryptoKey,
 encryptIterations: 200000 // PBKDF2 default
});
Wire-protocol headers (sent on every chunk / single upload)
X-Mu-Encryption-Algo: aes-gcm-256
X-Mu-Encryption-Salt: <16 random bytes, base64url>
X-Mu-Encryption-IV: <12 random bytes, base64url>
X-Mu-Encryption-Iter: 200000
X-Mu-Encryption-Original-Name: <URL-encoded cleartext filename>
X-Mu-Encryption-Original-Size: <cleartext byte count>
Decryption in the browser
// Static helper: pass ciphertext blob, password, salt, iv (from response headers)
const cleartext = await MultipleUpload.decryptFile(
 ciphertextBlob,
 password,
 saltFromHeader,
 ivFromHeader,
 200000 // iterations, must match upload
);
// cleartext is a Blob - show it, save it, or pipe into a viewer
Decryption in Node.js
import { webcrypto as crypto } from 'node:crypto';

async function decrypt(ciphertext, password, saltB64, ivB64, iterations = 200000) {
 const b64u = s => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
 const baseKey = await crypto.subtle.importKey(
 'raw', new TextEncoder().encode(password),
 'PBKDF2', false, ['deriveKey']
 );
 const key = await crypto.subtle.deriveKey(
 { name: 'PBKDF2', salt: b64u(saltB64), iterations, hash: 'SHA-256' },
 baseKey,
 { name: 'AES-GCM', length: 256 }, false, ['decrypt']
 );
 return Buffer.from(await crypto.subtle.decrypt(
 { name: 'AES-GCM', iv: b64u(ivB64) }, key, ciphertext
 ));
}
Algorithm details
  • Cipher: AES-256-GCM (authenticated encryption - tamper-evident).
  • KDF: PBKDF2-HMAC-SHA256, 200,000 iterations by default (tunable).
  • Salt: 128-bit random per file. Never reused.
  • IV: 96-bit random per file. Never reused with the same key.
  • Auth tag: 128-bit GCM tag appended to ciphertext (handled by SubtleCrypto).
Safety
  • Insecure context (http://): SubtleCrypto unavailable -> encrypt task fails fast with encryption_failed. No silent fallback to cleartext.
  • Missing password: queued files fail with a clear error before any byte leaves the browser.
  • Resume after reload: with persistState the ciphertext is what's persisted - re-derivation isn't needed on resume.