Skip to content

Encryption at Rest

This guide covers storage-level encryption at rest – encrypting all data the server persists so it is protected even if someone gains direct access to your storage backend (Redis, S3, filesystem, database).

  • Encrypting data at the storage layer with a server-held key
  • Wrapping an unstorage driver with createEncryptedDriver so all values are encrypted before they hit disk/Redis/S3
  • Generating, importing, and rotating the server-side at-rest key
  • Keeping at-rest encryption independent of client-side content E2EE
  • Combining both layers for defense in depth

At-rest encryption is a driver-level concern. createEncryptedDriver (from teleportal/storage) wraps any unstorage driver so every value is encrypted with a server-held AES-256-GCM key before it is written, and decrypted when read. It is built on a generic TransformDriver, and for each value it lib0-encodes, encrypts (via encryptUpdate, with a random 12-byte IV per write), then base64-stores the result.

import { Server } from "teleportal/server";
import { UnstorageDocumentStorage, createEncryptedDriver } from "teleportal/storage";
import { generateEncryptionKey, importEncryptionKey } from "teleportal/encryption-key";
import { createStorage } from "unstorage";
import redisDriver from "unstorage/drivers/redis";
// --- 1. Import or create the server-side at-rest key (a raw CryptoKey) ---
const AT_REST_KEY = process.env.AT_REST_KEY
? await importEncryptionKey(process.env.AT_REST_KEY)
: await generateEncryptionKey(); // only for dev -- persist this in production
// --- 2. Wrap the underlying driver with encryption ---
const encryptedStorage = createStorage({
driver: createEncryptedDriver(
redisDriver({
base: "teleportal:",
url: process.env.REDIS_URL ?? "redis://localhost:6379",
}),
AT_REST_KEY,
),
});
// --- 3. Use the encrypted storage with the server ---
const server = new Server({
storage: async (ctx) => {
return new UnstorageDocumentStorage(encryptedStorage, {
keyPrefix: "doc",
encrypted: ctx.encrypted,
});
},
});

Every value written to Redis is now AES-256-GCM encrypted with a unique IV. If an attacker accesses Redis directly, they see only ciphertext.

The second argument to createEncryptedDriver can be a CryptoKey, a Promise<CryptoKey>, or a (key: string) => CryptoKey | Promise<CryptoKey> function if you want to vary the key per storage key.

The at-rest key is a symmetric AES-256-GCM key that the server holds. It is completely separate from the client-side content E2EE key that clients pass to Provider.create.

import {
generateEncryptionKey,
exportEncryptionKey,
importEncryptionKey,
} from "teleportal/encryption-key";
// Generate a new random key and export it as a JWK string for storage
const key = await generateEncryptionKey();
const keyString = await exportEncryptionKey(key);
// Store keyString in a secrets manager or environment variable -- treat it as a secret.
// In production, load from the environment
const atRestKey = await importEncryptionKey(process.env.AT_REST_KEY!);

To rotate the at-rest key, re-encrypt existing data with the new key:

  1. Store the new key alongside the old one.
  2. On reads, try the new key first; fall back to the old key if decryption fails.
  3. On writes, always use the new key.
  4. Run a background migration to re-encrypt all existing values, then remove the old key.

The encrypted option on UnstorageDocumentStorage reflects the content encryption mode of the document – the client-side E2EE feature. It tags document metadata so the server knows whether the stored content sidecars are encrypted.

const server = new Server({
storage: async (ctx) => {
return new UnstorageDocumentStorage(encryptedStorage, {
keyPrefix: "doc",
// ctx.encrypted is true when the client connected with an encryptionKey.
// It only tags metadata -- it does NOT control at-rest encryption.
encrypted: ctx.encrypted,
});
},
});

This flag is unrelated to the at-rest encryption key used by the storage wrapper. Wire ctx.encrypted through so metadata stays accurate regardless of your at-rest configuration.

For maximum security, use both layers together. No code changes are needed beyond what is shown above – content E2EE is on by default, and the encrypted driver is transparent to UnstorageDocumentStorage.

On the client, Provider.create encrypts content before it leaves the browser:

import { Provider } from "teleportal/providers";
import { createEncryptionKey } from "teleportal/encryption-key";
const provider = await Provider.create({
url: "wss://example.com/sync",
document: "my-doc",
encryptionKey: createEncryptionKey(), // never sent to server
});

On the server, use the createEncryptedDriver wrapper from the example above. With both layers active, document content is encrypted by the client before it leaves the browser, and then the server encrypts all persisted bytes (CRDT structure, metadata, encrypted sidecars) again before writing to storage. An attacker who compromises the storage backend sees only AES-256-GCM ciphertext. An attacker who compromises the server process sees encrypted content sidecars but never the plaintext content.