---
title: encryption
description: 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](/bulk) calls alike. It's the canonical [wrap plugin](/plugins#wrap-intercepting-operations): 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](https://developer.mozilla.org/en-US/docs/Web/API/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](/api/upload).

```ts lineNumbers
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:

1. On `upload`, it generates a fresh, random **data key (DEK)** for that single object and encrypts the body with it using AES-256-GCM.
2. It then encrypts ("wraps") that DEK with the **master key (KEK)** you passed in.
3. 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`](/api/stored-file) - 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):

```ts lineNumbers
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"));
```

<Callout type="warn">
  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.
</Callout>

## 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:

```ts
plugins: [compression(), encryption(key)];
```

Because reads unwind the [onion](/plugins#wrap-intercepting-operations) in reverse, a download automatically runs decrypt → decompress. You never hand-manage the symmetry.

## Things to keep in mind

<Callout type="warn">
  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](/resumable), which re-read the original body.
</Callout>

- **Range downloads throw.** A slice of a GCM ciphertext can't be decrypted, so a [`download`](/api/download) with a `range` is refused.
- **`url()` and `signedUploadUrl()` 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.
- **`copy` and `move` just 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](/api/upload) - an `upload` to 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.
