An encrypted virtual filesystem for PGlite. Provides transparent AES-256-GCM page-level encryption so your PGlite database files are encrypted at rest.
Status: Alpha. The on-disk format is not yet versioned and may change before 1.0.
PGlite gives you a full embedded PostgreSQL in Node.js -- SQL, indexes, transactions, extensions like pgvector, all without a server. But out of the box, database files sit plaintext on disk.
This package is an encrypted VFS that plugs into PGlite's filesystem layer, encrypting every page as it's written and decrypting as it's read. Your PGlite code stays the same -- you just pass an EncryptedFS instance at creation.
- AES-256-GCM authenticated encryption -- page-level encryption with integrity verification on every read
- PBKDF2-SHA512 key derivation -- 256K iterations, aligned with OWASP Password Storage Cheat Sheet guidance
- Near-zero read overhead -- decrypted pages are cached in PostgreSQL's buffer pool; subsequent reads hit the cache
- AAD binding prevents page swapping/replay attacks -- each page is bound to its file identity and position
- Passphrase verification on reopen -- a wrong key is detected immediately, before any data is served
- Transparent to PGlite extensions -- pgvector and other extensions work normally on top of the encrypted VFS
pglite-encrypted-fs requires @electric-sql/pglite as a peer dependency.
# pnpm
pnpm add pglite-encrypted-fs @electric-sql/pglite
# npm
npm install pglite-encrypted-fs @electric-sql/pglite
# yarn
yarn add pglite-encrypted-fs @electric-sql/pgliteimport { PGlite } from '@electric-sql/pglite'
import { EncryptedFS } from 'pglite-encrypted-fs'
const dataDir = './my-encrypted-db'
const fs = new EncryptedFS(dataDir, 'my-secret-passphrase')
const db = await PGlite.create({ dataDir, fs })
await db.exec('CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)')
await db.exec("INSERT INTO users (name) VALUES ('Alice')")
const result = await db.query('SELECT * FROM users')
console.log(result.rows) // [{ id: 1, name: 'Alice' }]
await db.close()Use the same passphrase -- the salt is stored automatically.
const fs = new EncryptedFS(dataDir, 'my-secret-passphrase')
const db = await PGlite.create({ dataDir, fs })
// Your data is still there, decrypted transparentlyIf the passphrase is wrong, the constructor throws immediately with "Invalid passphrase or corrupted encryption keys".
PGlite extensions work normally on top of the encrypted VFS. Here's pgvector:
import { PGlite } from '@electric-sql/pglite'
import { vector } from '@electric-sql/pglite/vector'
import { EncryptedFS } from 'pglite-encrypted-fs'
const dataDir = './my-encrypted-vectors'
const fs = new EncryptedFS(dataDir, process.env.DB_PASSPHRASE!)
const db = await PGlite.create({ dataDir, fs, extensions: { vector } })
await db.exec('CREATE EXTENSION IF NOT EXISTS vector')
await db.exec('CREATE TABLE docs (id serial PRIMARY KEY, embedding vector(3))')
await db.exec("INSERT INTO docs (embedding) VALUES ('[0.1, 0.2, 0.3]')")
const { rows } = await db.query(
"SELECT * FROM docs ORDER BY embedding <-> '[0.1, 0.2, 0.3]' LIMIT 5"
)
console.log(rows)
await db.close()
fs.destroy()Creates an encrypted filesystem instance.
| Parameter | Type | Description |
|---|---|---|
dataDir |
string |
Path to the database directory on disk |
passphrase |
string |
Your encryption passphrase |
options |
{ debug?: boolean } |
Optional. Enable debug logging with { debug: true } |
The constructor creates the data directory if it does not exist. On first use, it generates a random salt and creates a verification token. On subsequent opens, it reads the salt from the existing verification token and verifies the passphrase is correct.
Derives a 256-bit encryption key from a passphrase using PBKDF2-SHA512 with 256,000 iterations.
| Parameter | Type | Description |
|---|---|---|
passphrase |
string |
The user's password or passphrase |
salt |
Buffer |
A 16-byte salt (from randomSalt() or stored) |
Returns { encKey: Buffer }. Takes approximately 48ms on modern hardware.
Returns a 16-byte cryptographically random Buffer suitable for use with deriveKeys().
Zeros the encryption key and salt from memory. Call this after closing PGlite to reduce the window of key exposure in heap dumps. Note that JavaScript's garbage collector may have already copied the data, so complete erasure is not guaranteed.
| Constant | Value | Description |
|---|---|---|
PAGE_SIZE |
8192 |
PostgreSQL page size (8KB) |
SALT_SIZE |
16 |
Salt length in bytes |
FILE_HEADER_SIZE |
48 |
File header: 16B salt + 32B file ID |
KDF_ITERATIONS |
256000 |
PBKDF2 iteration count |
AES-256-GCM with a random 12-byte IV generated per page write. The authentication tag (16 bytes) ensures both confidentiality and integrity.
Each 8KB plaintext page becomes 8,220 bytes on disk:
[IV (12B)][Auth Tag (16B)][Ciphertext (8192B)]
Every encrypted file on disk has this structure:
[Header (48B)][Encrypted Page 0][Encrypted Page 1][...]
Header = [Salt (16B)][File ID (32B)]
Each page's GCM authentication tag covers:
AAD = [File ID (32B)][Page Number (4B)]
This prevents two classes of attack:
- Intra-file page swapping -- moving page 5 to page 3's slot within the same file is detected
- Cross-file page swapping -- copying a page from one file into another file is detected
Each file receives a random 32-byte identifier stored in its header. Because file IDs are not derived from the file path, encrypted files survive renames without breaking authentication.
On first initialization, a .encryption-verify file is created containing the 16-byte salt followed by a known magic value encrypted with the derived key. On every subsequent open, the salt is read from this file, the key is re-derived, and the magic value is decrypted and checked. A wrong passphrase fails immediately rather than silently serving corrupted data.
The following PostgreSQL metadata files are left unencrypted because they contain no user data and PostgreSQL requires them in plaintext:
.conffiles (configuration).pidfiles (process ID)PG_VERSIONpg_internal.initpostmaster.*.lockfilesreplorigin_checkpoint
This provides at-rest encryption of PGlite/PostgreSQL database files on disk. It protects against offline theft or unauthorized access to the stored files.
Non-goals:
- Does not protect against an attacker who can run code in your process
- Data is decrypted in memory during query execution (like any database encryption-at-rest)
- JavaScript runtimes cannot guarantee secure key erasure (
destroy()is best-effort) - This package has not been independently audited. If you find a vulnerability, please report it privately via GitHub.
Benchmarks measured on Node.js (see pnpm run bench for your own results):
| Operation | Plain | Encrypted | Overhead |
|---|---|---|---|
| Insert 100 rows | 11.2ms | 13.6ms | +22% |
| Bulk insert 1,000 rows | 5.5ms | 10.5ms | +93% |
| Select 1,000 rows | 0.55ms | 0.54ms | ~0% |
| Select with index | 0.090ms | 0.092ms | ~0% |
| Aggregate (COUNT/SUM) | 0.100ms | 0.098ms | ~0% |
| Mixed CRUD cycle | 0.42ms | 0.48ms | +15% |
| Fresh database init | 531ms | 835ms | +57% |
| Database reopen | 36ms | 86ms | +138% |
| Single page encrypt | -- | 5.4us | -- |
| Single page decrypt | -- | 3.9us | -- |
| Key derivation (PBKDF2) | -- | 48ms | -- |
Read operations have near-zero overhead because data is decrypted when pages are loaded into PostgreSQL's buffer pool. Subsequent reads hit the cache, so reads are only slow the first time a page is loaded. Write overhead comes from per-page encryption. Run benchmarks yourself with pnpm run bench.
| Platform | Supported |
|---|---|
| Node.js (>=20) | Yes |
| Bun | Yes (untested) |
| Deno | No |
| Chrome | No |
| Safari | No |
| Firefox | No |
This package uses Node.js crypto and fs modules and is not compatible with browser environments.
Can I rotate the passphrase?
Not in-place. Export your data with pg_dump (or application-level export), create a new encrypted database with the new passphrase, and re-import.
Can I migrate an existing plaintext PGlite database?
Same approach -- dump and re-import into a new encrypted database.
Why not SQLCipher?
SQLCipher encrypts SQLite databases. This package encrypts PGlite/PostgreSQL databases. If you're already using PGlite for its PostgreSQL features (extensions, SQL semantics, pgvector), this adds at-rest encryption without changing databases.
git clone https://github.com/davidmuggleton/pglite-encrypted-fs.git
cd pglite-encrypted-fs
pnpm install
pnpm test
pnpm run benchMIT -- see LICENSE.