encryption
Envelope-encrypt object bodies at rest with AES-256-GCM. A per-object data key encrypts the body and your master key wraps it into metadata - provider-agnostic, no native dependencies, decrypted transparently on download.
The built-in encryption() plugin encrypts every body at rest and decrypts it on the way back out - transparently, for single and bulk calls alike. It's the canonical wrap plugin: it transforms the body on upload, reverses it on download, and round-trips its key material through the object's metadata.
It uses only the Web Crypto API, so it has no native dependencies and runs anywhere the SDK does - Node, Bun, Deno, edge runtimes, and the browser. It works on any adapter that supports metadata.
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { encryption, generateEncryptionKey } from "files-sdk/encryption";
const key = await generateEncryptionKey();
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [encryption(key)],
});
await files.upload("secret.txt", "hello"); // stored encrypted
await (await files.download("secret.txt")).text(); // "hello"How it works
The plugin uses envelope encryption, the same pattern cloud KMS services use:
- On
upload, it generates a fresh, random data key (DEK) for that single object and encrypts the body with it using AES-256-GCM. - It then encrypts ("wraps") that DEK with the master key (KEK) you passed in.
- The wrapped DEK and the initialization vectors travel alongside the object in its
metadata; the stored bytes are pure ciphertext.
On download it unwraps the DEK with your master key, decrypts the body, and hands you back a normal StoredFile - with its size reporting the plaintext length and the internal metadata fields hidden.
A fresh per-object DEK means the same master key is never reused to encrypt two bodies directly, which sidesteps IV-reuse concerns at scale and leaves room for key rotation later (re-wrap the DEK without re-encrypting the body).
Managing the key
encryption() accepts either a Web Crypto CryptoKey or raw AES key bytes (16, 24, or 32 bytes):
import { encryption, generateEncryptionKey } from "files-sdk/encryption";
// Generate one and persist the raw bytes in your secret manager:
const key = await generateEncryptionKey();
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", key));
// Later, rebuild the plugin from a 32-byte secret (e.g. an env var):
encryption(Buffer.from(process.env.FILES_ENCRYPTION_KEY!, "base64"));The master key is the only thing that can decrypt your data. If you lose it, the objects are unrecoverable; if it leaks, they're exposed. Store it in a secret manager - never in source control.
Ordering
Encryption should be the innermost layer, so put it last in the array. Anything that needs to see plaintext - compression, validation, virus scanning - must run before it:
plugins: [compression(), encryption(key)];Because reads unwind the onion in reverse, a download automatically runs decrypt → decompress. You never hand-manage the symmetry.
Things to keep in mind
AES-GCM authenticates the whole ciphertext, so the plugin buffers the entire body in memory to encrypt or decrypt it. It's unsuitable for unknown-length streams and resumable uploads, which re-read the original body.
- Range downloads throw. A slice of a GCM ciphertext can't be decrypted, so a
downloadwith arangeis refused. url()andsignedUploadUrl()throw. A presigned URL bypasses the plugin entirely - a signed GET would hand out ciphertext nobody can decrypt, and a signed PUT would let a client store unencrypted bytes. Both fail closed.copyandmovejust work. They operate on the stored ciphertext server-side, and the wrapped DEK rides along in the object's metadata, so the copy still decrypts.- Mixed buckets are safe. On read, objects without this plugin's marker (pre-existing data, or anything written elsewhere) pass straight through unchanged, so you can enable it on a bucket that already holds plaintext.
- It needs metadata support. The wrapped DEK and IVs are stored as object metadata, so the adapter must support metadata - an
uploadto one that doesn't throws before any bytes move.
What it stores in metadata
Each encrypted object carries a few fsenc_-prefixed metadata fields (the scheme marker, the wrapped DEK, and the IVs). They're stripped from the StoredFile you get back on download, head, and list, so your own metadata is all you see.
dedup
Content-address object bodies by their hash so identical content is stored only once. Re-uploading the same bytes skips the upload, and copies share a single stored blob. No native dependencies; works on any adapter that supports metadata.
tracing
Open an OpenTelemetry span around every operation on a Files instance - one span per call, named files.<verb>, with the key, size, and outcome as attributes and errors recorded with an ERROR status. Spans nest under your active request span. Uses the optional @opentelemetry/api peer dependency.