Extensions
- pg_vault_tde 1.7.0
- Transparent Data Encryption (TDE) for PostgreSQL via a custom Table Access Method
Documentation
- Logical-Replication
- Logical Replication
- Glossary
- Glossary
- Backup-and-Restore
- Backup and Restore
- KMS-PKCS11-HSM
- KMS: PKCS#11 / HSM
- Installation
- Installation
- Security-Considerations
- Security Considerations
- KMS-HashiCorp-Vault-OpenBao
- KMS: HashiCorp Vault / OpenBao
- testing.instructions
- Testing & QA Instructions — @QA
- tam.instructions
- TAM Module Instructions — @Architect
- CONTRIBUTING
- Contributing
- PGXN
- PGXN’s own build/test recipe — must pass before uploading:
- Known-Limitations-and-Troubleshooting
- Known Limitations and Troubleshooting
- Compatibility-and-Versioning
- Compatibility and Versioning
- crypto.instructions
- Crypto Module Instructions — @SecurityKMS
- CONTRIBUTORS
- Contributors
- Auditing-and-Monitoring
- Auditing and Monitoring
- README
- ci/ — Local Testing Pipeline
- Key-Management-Overview
- Key Management Overview
- Performance-and-Tuning
- Performance and Tuning
- SQL-Function-Reference
- SQL Function Reference
- Getting-Started
- Getting Started
- Key-Rotation
- Key Rotation
- logical_decoding_research
- Logical Decoding Output Plugin for pg_vault_tde — Technical Research Report
- GUC-Reference
- GUC Reference
- packaging.instructions
- DevOps & Packaging Instructions — @DevOps
- ROADMAP
- pg_vault_tde Roadmap
- iam.instructions
- IAM Module Instructions — @Architect + @SecurityKMS
- kms.instructions
- KMS Module Instructions — @SecurityKMS
- Home
- pg_vault_tde
- KMS-Local-Wallet
- KMS: Local Wallet
- Encrypted-Tables-and-Indexes
- Encrypted Tables and Indexes
README
Contents
pg_vault_tde
Transparent Data Encryption (TDE) for PostgreSQL 17+ — Open-source (PostgreSQL License), plug-and-play, zero core modifications.
pg_vault_tde encrypts every tuple with AES-256-GCM at the Table Access Method layer. Data is encrypted before it reaches the storage manager and decrypted after it leaves. Encryption keys are managed by HashiCorp Vault / OpenBao or a local PKCS#12 wallet and cached in shared memory with automatic rotation.
Current release: v1.7 — 109 regression tests (52 v1.4 + 20 v1.5 + 37 v1.6), zero compiler warnings on PG 17 + PG 18.
Commercial Support
Looking for professional support for pg_vault_tde in production? At Miriade, we offer dedicated enterprise services, including:
- 24/7 Production Support & SLA Guarantees
- Custom Feature Development & Vault Integration
- Performance Tuning & Security Audits
- Managed Setup & Migration Assistance
Learn more about our enterprise encryption solutions at Mircrypt
Contact our engineering team at marketing@miriade.it to discuss your requirements.
PostgreSQL Version Compatibility
| PG Major | Status | Notes |
|---|---|---|
| 17 | ✅ Supported | Baseline API set |
| 18 | ✅ Supported | scan_bitmap_next_tuple signature change (guarded) |
| 19 | 🔜 Planned | Infrastructure ready; audit at release |
Quick Start
1. Install
# Build and install into your PostgreSQL instance
git clone https://github.com/miriade/pg_vault_tde.git
cd pg_vault_tde
# On Debian/Ubuntu (PG 18)
apt-get install -y postgresql-server-dev-18 libssl-dev libcurl4-openssl-dev pkg-config
make && sudo make install
# On Debian/Ubuntu (PG 17)
apt-get install -y postgresql-server-dev-17 libssl-dev libcurl4-openssl-dev pkg-config
make PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config && sudo make install
# On RHEL/Rocky (PG 18)
dnf install -y postgresql18-devel openssl-devel libcurl-devel
make PG_CONFIG=/usr/pgsql-18/bin/pg_config && make install
# On RHEL/Rocky (PG 17)
dnf install -y postgresql17-devel openssl-devel libcurl-devel
make PG_CONFIG=/usr/pgsql-17/bin/pg_config && make install
Or via PGXN (same OS build
dependencies as above are still required — pgxn install just runs the
build for you):
pip install pgxnclient # or: apt-get install pgxnclient / dnf install pgxnclient
pgxn install pg_vault_tde
See wiki: Installation
for package-based (.deb/.rpm) installs and full per-OS prerequisites.
2. Configure PostgreSQL
Add to postgresql.conf:
shared_preload_libraries = 'pg_vault_tde'
Restart PostgreSQL and create the extension in your database:
CREATE EXTENSION pg_vault_tde;
3. Configure Key Access
a) HashiCorp Vault / OpenBao
kms_provider has no built-in default — it must be set explicitly. Set GUC
parameters in postgresql.conf (or ALTER SYSTEM) to point at your Vault /
OpenBao instance:
pg_vault_tde.kms_provider = 'vault'
pg_vault_tde.vault_url = 'https://vault.example.com:8200'
pg_vault_tde.vault_namespace = '' # leave empty for community edition
pg_vault_tde.vault_token = 'hvs.TOKEN' # or use AppRole (1.1)
pg_vault_tde.vault_transit_mount = 'transit'
pg_vault_tde.vault_key_name = 'pg-tde-dek'
pg_vault_tde.vault_ca_cert = '/etc/ssl/vault/ca.pem'
pg_vault_tde.vault_timeout_ms = 5000
pg_vault_tde.enabled = on # PGC_POSTMASTER: requires a full restart to change
⚠️ Warning — do not toggle
pg_vault_tde.enabledon a live database. Rows are written using the wire format active at the time of the write (encrypted v4 trailer whenon, verbatim heap tuple whenoff). Changing the setting and restarting does not retroactively convert existing rows: reads use whatever format is currently active for the entire table, so anyencrypted_heaptable containing rows written under the other setting will have those old rows misread (silent data corruption, not an error). Only toggle this setting on databases whereencrypted_heaptables are empty or have been fully migrated (e.g. rewritten viaCREATE TABLE ... AS SELECTunder the target setting) beforehand.
b) Local Wallet (keypass locale, v1.6+)
To use a local PKCS#12 wallet (no external KMS, suitable for offline/air-gapped/standalone):
- Set the following in
postgresql.conf:
pg_vault_tde.kms_provider = 'local'
# wallet_path defaults to /var/lib/pg_vault_tde/<DB_OID>/wallet.p12 — omit unless overriding:
# pg_vault_tde.wallet_path = '/custom/path/to/wallet.p12'
pg_vault_tde.wallet_passphrase_env = 'TDE_WALLET_PASSPHRASE' # env var name only
pg_vault_tde.wallet_auto_open = on
pg_vault_tde.enabled = on
- Set the passphrase in the environment before starting PostgreSQL:
export TDE_WALLET_PASSPHRASE='my-strong-wallet-passphrase'
- Initialize the wallet (first time only, as superuser):
-- In psql: \set reads the shell variable without exposing the value in
-- pg_stat_activity or server logs (note: backslash-set is a psql meta-command)
\set PASSPHRASE `echo $TDE_WALLET_PASSPHRASE`
SELECT pg_vault_tde_wallet_init(:'PASSPHRASE');
- Check wallet status:
SELECT * FROM pg_vault_tde_wallet_status();
- Unlock/lock wallet interactively (no restart needed):
SELECT pg_vault_tde_wallet_unlock('my-strong-wallet-passphrase');
SELECT pg_vault_tde_wallet_lock();
Tip: You can also use
wallet_passphrase_fileorwallet_passphrase_commandinstead of an environment variable. See the GUC Parameters section for details.
c) PKCS#11 / HSM (v1.7)
To keep the KEK inside a hardware security module (or any device exposing a
PKCS#11 module — Thales, Utimaco, YubiHSM, AWS CloudHSM, SoftHSM2 for
testing). The extension loads the vendor’s module directly and wraps every
per-table DEK with C_WrapKey (CKM_AES_KEY_WRAP, RFC 3394) against an
AES-256 KEK that never leaves the token.
- Set the following in
postgresql.conf:
pg_vault_tde.kms_provider = 'pkcs11'
pg_vault_tde.pkcs11_library = '/usr/lib/softhsm/libsofthsm2.so' # vendor module
pg_vault_tde.pkcs11_token_label = 'pgtde' # preferred over pkcs11_slot_id
# pg_vault_tde.pkcs11_pin_env = 'PG_TDE_PKCS11_PIN' # env var NAME (default)
# pg_vault_tde.pkcs11_key_label = 'pg_vault_tde_kek' # KEK CKA_LABEL (default)
pg_vault_tde.enabled = on
- Export the token user PIN in the server environment before starting PostgreSQL (the GUC holds the env var name, never the PIN itself):
export PG_TDE_PKCS11_PIN='1234'
- Generate the KEK on the token (first time only, as superuser):
SELECT pg_vault_tde_pkcs11_keygen();
The KEK is created with CKA_SENSITIVE and CKA_EXTRACTABLE=FALSE: it can
never be read out of the device. KEK rotation goes through the standard
SELECT pg_vault_tde_rotate_kek(); — each generation is kept on the token
forever as an immutable object labelled <pkcs11_key_label>.v<N> (never
renamed or destroyed), so old data always decrypts regardless of which
version is “current”. A backend that is already connected when a rotation
completes elsewhere picks it up automatically, on its next encrypt/decrypt
call — no reconnect required.
Testing without an HSM: initialize a SoftHSM2 token with
softhsm2-util --init-token --free --label pgtde --pin 1234 --so-pin 12345(packagesofthsm2; setSOFTHSM2_CONFfor a custom token directory). Seetap/16_pkcs11.tfor a complete self-contained example.Limitation: the standalone backup tools (
pg_dump_tde/pg_restore_tde) do not supportkms_provider = 'pkcs11'yet and exit with a clear error.
4. Create an Encrypted Table
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text,
ssn text,
dob date
) USING encrypted_heap;
INSERT INTO users (email, ssn, dob)
VALUES ('alice@example.com', '123-45-6789', '1990-01-15');
-- Data is transparently decrypted on read
SELECT email, ssn FROM users WHERE id = 1;
What Gets Encrypted
| Layer | Encrypted? | Notes |
|---|---|---|
| Tuple user data | ✅ Yes — AES-256-GCM | All column values in encrypted_heap tables |
| HeapTupleHeader | ✗ No | xmin, xmax, ctid, infomask — required for MVCC |
| Index keys (B-Tree) | ⚠️ Optional — tde_btree |
AES-256-SIV — equality only; all types encrypted (v1.7); index-only scans not supported |
| Index keys (GIN, Hash) | 🔜 v1.8 | GIN for jsonb/arrays; Hash for equality hashing |
| Index keys (GiST equality) | 🔜 v1.8 | Equality-only GiST (inet_ops); range/geometric GiST permanently deferred |
| Index keys (BRIN bloom) | 🔜 v1.8 | Equality-only block-range pruning via a bloom filter over ciphertext hashes; minmax BRIN permanently deferred (needs a spike — see doc/ROADMAP.md) |
| TOAST values | ✅ Yes | Heap-level round-trips functional; per-chunk storage encryption |
| Column-level granularity | 🔜 v1.8 | Per-column ENABLE COLUMN ENCRYPTION DDL |
| WAL / redo log | ✅ Yes | Data encrypted before heap_insert() |
| pg_statistic | 🔜 v1.8 | Statistics stored plaintext; MCVs/histograms expose value distribution |
Column-level: Only tables created with
USING encrypted_heapare encrypted. Regularheaptables are unaffected.Index access method whitelist:
CREATE INDEX/CREATE UNIQUE INDEXwith any access method other thantde_btree(so alsogin,gist,hash,brin) against anencrypted_heaptable is rejected withERRORby default — the indexed column’s plaintext value would otherwise sit unencrypted on disk. Setpg_vault_tde.allow_plaintext_index = onto allow it anyway (with aWARNING) until GIN/Hash/GiST encryption ships in v1.8.PRIMARY KEY/UNIQUEtable constraints are a separate, unavoidable case — PostgreSQL core always backs them with a native btree index — and are always allowed with aWARNING, regardless of this setting.
Architecture
SQL Layer
│
▼
Table Access Method (TAM) — encrypted_heap src/tam/
│ ┌─ tuple_insert ──► tde_encrypt_heap_tuple ──► heap_insert
│ ├─ tuple_update ──► tde_encrypt_heap_tuple ──► heap_update
│ ├─ multi_insert ──► tde_encrypt × N ──────► heap_insert × N
│ ├─ scan_getnextslot ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ index_fetch_tuple ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ scan_bitmap_next_tuple ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ scan_analyze_next_tuple ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ scan_sample_next_tuple ──► heapam ──► decode_slot ──► tde_decrypt
│ ├─ tuple_fetch_row_version ──► heapam ──► decode_slot ──► tde_decrypt
│ └─ tuple_lock ──► heapam ──► decode_slot ──► tde_decrypt
│
▼
Index Access Method (IAM) — tde_btree src/iam/
│ AES-256-SIV (OpenSSL 3.x EVP_CIPHER_fetch) — deterministic equality
│ 64-byte double-key via PBKDF2-SHA256 from DEK
│
▼
Crypto Layer — AES-256-GCM (OpenSSL 3.x EVP) src/crypto/
│ [IV(12) | CIPHERTEXT | GCM-TAG(16) | VER(1) | GEN(8)] per tuple
│ Per-backend EVP_CIPHER_CTX cached & keyed by (relid, generation):
│ AES key schedule reused across tuples, only the IV rearmed per call
│ IV batch generation: 256 IVs per pg_strong_random() call
│
▼
KMS Layer — per-relation DEK cache src/kms/
│ ┌─ TdeRelDekMap (shmem HTAB, one shared LWLock, per-relation generation)
│ └─ pg_vault_tde_catalog (on-disk wrapped DEKs, one row per relation)
│
▼
HashiCorp Vault / OpenBao (GUC-configurable endpoint)
Wire Format (on disk, per tuple)
v4 format (default for encrypted_heap tables):
┌─────────────────────────────────┬───────────────────────────────────────────────────────┐
│ HeapTupleHeader (t_hoff bytes) │ IV(12) │ Ciphertext │ GCM-Tag(16) | VER(1) │ GEN(8) |
│ PLAINTEXT — MVCC fields │ │
└─────────────────────────────────┴───────────────────────────────────────────────────────┘
←───────────── TDE_V4_OVERHEAD = 37 bytes ─────────────→
v4 overhead: 37 bytes per tuple (12-byte IV + 16-byte GCM authentication tag +
1-byte version 0x04 + 8-byte DEK generation counter).
The IV-first layout keeps the version/generation bytes at the end so the blob has no
byte-stable prefix — this is what structurally disables HOT updates (see
Limitations).
v4 also passes [database_oid(4) | relid(4) | generation(8)] as AEAD Additional
Authenticated Data (AAD) — zero wire overhead; prevents cross-table ciphertext smuggling.
Key Management
KMS Provider Selection
pg_vault_tde supports multiple KMS backends via a provider abstraction layer
(introduced in v1.5). kms_provider has no built-in default — it is an
empty string until set, which the extension treats as “not yet configured.”
Select the provider explicitly with:
pg_vault_tde.kms_provider = 'vault' # HashiCorp Vault / OpenBao
# pg_vault_tde.kms_provider = 'local' # Local wallet (PKCS#12, no external service) (v1.6)
# pg_vault_tde.kms_provider = 'pkcs11' # HSM via a PKCS#11 module (v1.7)
# pg_vault_tde.kms_provider = 'kmip' # KMIP 1.2 (v1.8, not implemented yet)
Per-Database KMS Configuration
Because all pg_vault_tde KMS-provider GUC parameters are declared PGC_SUSET (the master enabled switch and a couple of shared-memory-sizing parameters are PGC_POSTMASTER and cannot be scoped per database — see doc/pg_vault_tde.md), a superuser can assign different KMS settings to individual databases in the same cluster without restarting PostgreSQL.
Each connection picks up the effective GUC value for its own database, so postgres can use a central Vault instance while tenant_a uses a dedicated transit key and tenant_b uses a local wallet:
-- cluster-level default (postgresql.conf / ALTER SYSTEM)
-- pg_vault_tde.kms_provider = 'vault'
-- database "tenant_a" uses a dedicated Vault transit key
ALTER DATABASE tenant_a SET pg_vault_tde.vault_key_name = 'tde-dek-tenant-a';
ALTER DATABASE tenant_a SET pg_vault_tde.vault_transit_mount = 'transit-tenants';
-- database "tenant_b" uses a local wallet (no Vault dependency)
ALTER DATABASE tenant_b SET pg_vault_tde.kms_provider = 'local';
ALTER DATABASE tenant_b SET pg_vault_tde.wallet_passphrase_env = 'TDE_WALLET_B';
-- verify effective settings for a database
\connect tenant_b
SHOW pg_vault_tde.kms_provider; -- 'local'
SELECT pg_vault_tde_health_check();
Settings applied with ALTER DATABASE SET take effect for new connections
to that database and do not require a server restart. The cluster-level defaults
in postgresql.conf (or ALTER SYSTEM) act as the fallback for any database
that does not override a parameter.
Local Wallet Provider (v1.6 — Offline, No External Service)
A PKCS#12-based encrypted file at
/var/lib/pg_vault_tde/<DB_OID>/wallet.p12 protects the KEK — deliberately
outside PGDATA, so a plain pg_basebackup does not copy it alongside the
wrapped DEKs it protects. No network dependency.
Suitable for single-server deployments, air-gapped environments, and development.
pg_vault_tde.kms_provider = 'local'
# wallet_path defaults to /var/lib/pg_vault_tde/<DB_OID>/wallet.p12 — omit unless overriding:
# pg_vault_tde.wallet_path = '/custom/path/to/wallet.p12'
pg_vault_tde.wallet_passphrase_env = 'TDE_WALLET_PASSPHRASE' # env var, never postgresql.conf
pg_vault_tde.wallet_auto_open = on
-- First-time wallet setup (\set reads the shell var without exposing it in logs):
\set PASSPHRASE `echo $TDE_WALLET_PASSPHRASE`
SELECT pg_vault_tde_wallet_init(:'PASSPHRASE');
-- Check status (5-column SRF):
SELECT * FROM pg_vault_tde_wallet_status();
-- Interactive unlock (without PG restart):
SELECT pg_vault_tde_wallet_unlock('my_passphrase');
-- Lock wallet (evict DEKs from shmem):
SELECT pg_vault_tde_wallet_lock();
-- Rotate KEK: generates a new KEK and re-wraps all per-table DEKs (works for the local, vault, and pkcs11 providers):
SELECT pg_vault_tde_rotate_kek();
Production (HashiCorp Vault / OpenBao)
The KMS layer calls Vault’s Transit secrets engine:
# Vault policy
path "transit/decrypt/pg-tde-dek" {
capabilities = ["update"]
}
path "transit/encrypt/pg-tde-dek" {
capabilities = ["update"]
}
The Vault endpoint, namespace, token, transit mount, key name, CA certificate and timeout are all configurable via GUC parameters registered at startup (see Configure Key Access).
DEK Cache (Shared Memory)
Since v1.7 the cache is a shared-memory hash table (HTAB) keyed by relid,
not a fixed array. A single LWLock from the "TdeRelDekMap" named tranche
guards the whole table (no per-entry lock).
TdeRelDekMap (shmem HTAB, ShmemInitHash, capacity = pg_vault_tde.max_encrypted_relations, default 1024)
└─ TdeRelDekMap entry, keyed by relid:
├─ relid : Oid (hash key)
├─ dek[32] : AES-256 key bytes (OPENSSL_cleanse'd on rotation)
├─ prev_dek[32] : previous DEK (rotation window fallback)
├─ generation : uint64 per-relation counter
└─ dek_valid / prev_dek_valid : bool
DEK access via pg_vault_tde_kms_get_rel_dek(relid):
1. Fast path: hash_search(HASH_FIND) under LW_SHARED — O(1) average; cache hit returns immediately.
2. Slow path (cache miss): catalog read (pg_vault_tde_catalog) -> KMS unwrap -> hash_search(HASH_ENTER) under LW_EXCLUSIVE.
Key Rotation
Per-table DEK rotation (re-encrypts all tuples with a new DEK, no exclusive lock):
SELECT pg_vault_tde_rotate_online('mytable', 1000);
-- Monitor progress:
SELECT * FROM pg_vault_tde_rotation_status('mytable');
rotate_online accepts both table relations and tde_btree index relations:
| Target | What happens |
|---|---|
encrypted_heap table |
Generates a new table DEK, re-encrypts every tuple in-place (RowExclusiveLock), then rebuilds any tde_btree indexes on the table so their SIV ciphertexts match the new DEK. Standard btree indexes on encrypted columns need no rebuild. |
tde_btree index |
Generates a new index DEK, then calls reindex_index (AccessExclusiveLock on the index only) to rebuild the index with keys encrypted under the new DEK. The parent table’s DEK and heap data are untouched. Passing a non-tde_btree index raises an error before touching shmem or the catalog. |
When a table with tde_btree indexes is rotated, the index rebuild uses the new table DEK
implicitly because the heap rows the scan reads are re-encrypted first; the index keys
are then produced from the decrypted values and re-encrypted under the (unchanged) index DEK.
To also rotate the index DEK, call rotate_online on the index relation directly afterwards.
KEK rotation (re-wraps all per-table DEKs under a new KEK — tuple data untouched):
-- Unified function — works for the local wallet, Vault Transit, and PKCS#11 providers:
SELECT pg_vault_tde_rotate_kek();
Note on
pg_vault_tde_wallet_change_passphrase(old, new): this function automatically rotates the KEK as part of the passphrase change. A separatepg_vault_tde_rotate_kek()call is unnecessary afterwards. The rationale: if an attacker already holds the old passphrase, they already have the old KEK — changing the passphrase without rotating the KEK provides no additional protection.
GUC Parameters
All parameters are in the pg_vault_tde namespace.
Most parameters have context suset (superuser-settable), meaning a superuser
can change them without restarting PostgreSQL and can scope them per-database
with ALTER DATABASE SET. The only exception is max_encrypted_relations,
which has context postmaster because it controls shared memory allocation at
startup.
Context summary:
- suset — superuser can SET at session level or via ALTER DATABASE SET /
ALTER ROLE SET; takes effect for new connections with no restart required.
- postmaster — requires a server restart; set in postgresql.conf or via
ALTER SYSTEM.
KMS Provider (v1.5+)
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
kms_provider |
string | '' (unset — must be configured) |
suset | Active KMS backend: vault, local (v1.6), pkcs11 (v1.7), kmip (v1.8). No default is shipped; encrypted tables cannot be used until this is set. Settable per-database via ALTER DATABASE SET. |
wallet_path |
string | /var/lib/pg_vault_tde/<DB_OID>/wallet.p12 |
suset | Local wallet PKCS#12 file path (kms_provider = 'local'). Default computed at runtime — SHOW returns the effective path even when not set in postgresql.conf. Deliberately outside PGDATA so a plain pg_basebackup does not copy it. |
wallet_passphrase_env |
string | '' |
suset | Env var name holding wallet passphrase — env var NAME only, never the value |
wallet_passphrase_file |
string | '' |
suset | File path containing wallet passphrase (trimmed; 0400 permission enforced) (v1.6) |
wallet_passphrase_command |
string | '' |
suset | Shell command to retrieve passphrase (analogous to PG’s ssl_passphrase_command) (v1.6) |
wallet_dev_mode_passphrase |
string | '' |
suset | Convenience passphrase for dev/CI (only honoured when dev_mode = on) (v1.6) |
dev_mode |
boolean | off |
suset | Enable development mode features (wallet_dev_mode_passphrase) (v1.6) |
wallet_auto_open |
boolean | on |
suset | Auto-open wallet on startup if passphrase env var is set |
max_encrypted_relations |
integer | 1024 |
postmaster | Maximum number of per-table DEK entries in shmem (64–65536). Requires restart — affects shared memory sizing. |
toast_encryption |
boolean | on |
suset | Encrypt TOAST chunks with the parent relation’s DEK (v1.5) |
Vault / OpenBao (kms_provider = 'vault')
All parameters are suset — settable per-database with ALTER DATABASE SET.
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
vault_url |
string | '' |
suset | Vault / OpenBao base URL |
vault_namespace |
string | '' |
suset | Vault namespace (enterprise; empty for community) |
vault_token |
string | '' |
suset | Auth token — hidden from pg_settings (superuser only) |
vault_role_id |
string | '' |
suset | AppRole role_id UUID |
vault_secret_id |
string | '' |
suset | AppRole secret_id — hidden from pg_settings (superuser only) |
vault_role_name |
string | '' |
suset | AppRole role name for secret_id rotation after login (v1.4) |
vault_k8s_role |
string | '' |
suset | Kubernetes JWT auth role name |
vault_transit_mount |
string | transit |
suset | Transit secrets engine mount path |
vault_key_name |
string | pg-tde-dek |
suset | Transit key name for DEK wrapping. Override per-database to isolate tenant keys. |
vault_ca_cert |
string | '' |
suset | Path to CA bundle for Vault TLS verification |
vault_timeout_ms |
integer | 5000 |
suset | Vault HTTP timeout in ms (0 = no timeout) |
PKCS#11 / HSM (kms_provider = 'pkcs11') (v1.7)
All parameters are suset — settable per-database with ALTER DATABASE SET.
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
pkcs11_library |
string | '' |
suset | Absolute path to the vendor’s PKCS#11 module (.so). Loaded lazily per backend. |
pkcs11_token_label |
string | '' |
suset | Token label for slot discovery. Preferred over pkcs11_slot_id (slot IDs are not stable across restarts on some modules, e.g. SoftHSM2). |
pkcs11_slot_id |
integer | -1 |
suset | Explicit slot ID, used only when pkcs11_token_label is empty (-1 = unset) |
pkcs11_pin_env |
string | PG_TDE_PKCS11_PIN |
suset | Env var name holding the token user PIN — env var NAME only, never the value |
pkcs11_key_label |
string | pg_vault_tde_kek |
suset | CKA_LABEL of the AES-256 KEK object on the token (create with pg_vault_tde_pkcs11_keygen()) |
Background Worker
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
bgw_enabled |
boolean | off |
suset | Enable background worker for automatic token renewal |
token_renewal_interval |
integer | 3600 |
suset | Token renewal interval in seconds (60–86400) |
General
| Parameter | Type | Default | Context | Description |
|---|---|---|---|---|
enabled |
boolean | on |
suset | Master switch — set off to measure TAM overhead without crypto. Settable per-database. |
allow_plaintext_index |
boolean | off |
suset | When off (default), CREATE INDEX/CREATE UNIQUE INDEX with a non-tde_btree access method on an encrypted_heap table is rejected with ERROR. When on, allowed after a WARNING — the indexed column’s plaintext value is then stored unencrypted on disk. Does not affect PRIMARY KEY/UNIQUE table constraints (always allowed, always warned — see “What Gets Encrypted” above). |
Auditing
pg_vault_tde emits an audit record for every security-relevant KMS and DDL event.
Auditing is always active: the audit handler is registered unconditionally at
_PG_init time and there is no GUC to disable it.
Each event is written to the PostgreSQL server log at LOG severity via
ereport(LOG) with errhidestmt and errhidecontext set, so the originating
SQL statement and context stack are suppressed — only the audit fields appear.
Log format
AUDIT: event=<name>, oid=<relation_oid_or_dash>, user=<role_name>, success=<t|f>, pid=<pid>
oid— relation OID affected by the event, or-for cluster-level events.success—ton success,fon failure (e.g. authentication error, GCM tag mismatch).
Logged events
The event= field carries the value below (note the DEK/KEK events drop the KMS_ prefix in the log line):
event= value |
Trigger | PCI DSS ref |
|---|---|---|
AUDIT_LOG_START |
Audit subsystem initialised at server start | 10.2.1.6 |
AUDIT_LOG_STOP |
Audit subsystem shut down | 10.2.1.6 |
DEK_ACCESS |
DEK read from shared-memory cache or KMS | — |
DEK_CREATE |
New per-relation DEK generated | — |
DEK_ROTATE |
Per-relation DEK rotated (pg_vault_tde_rotate_online) |
10.2.1.7 |
DEK_DELETE |
DEK revoked / removed from catalog (DROP TABLE) |
10.2.1.7 |
KEK_ROTATE |
KEK rotated (pg_vault_tde_rotate_kek) |
10.2.1.7 |
KMS_AUTH_SUCCESS |
KMS / Vault authentication succeeded | 10.2.1.5 |
KMS_AUTH_FAILURE |
KMS / Vault authentication failed | 10.2.1.5 |
WALLET_OPEN |
Local wallet opened (pg_vault_tde_wallet_unlock) |
— |
WALLET_CLOSE |
Local wallet closed (pg_vault_tde_wallet_lock) |
— |
RELATION_ENCRYPT |
Relation converted to encrypted_heap |
10.2.1.7 |
RELATION_DECRYPT |
encrypted_heap converted back to plain heap |
10.2.1.7 |
ACCESS_DENIED |
Decryption failed — wrong key or missing permission | 10.2.1.4 |
DEK_UPDATE and INTEGRITY_VIOLATION are defined in the audit enum but not yet
emitted by any code path (reserved for a future release).
Routing audit logs
Because audit records are written as PostgreSQL LOG messages they flow through
the standard log_destination / logging_collector pipeline. To route them to
a dedicated file or to an external SIEM, match on the AUDIT: prefix:
# postgresql.conf — route AUDIT lines to a separate file (requires logging_collector = on)
log_destination = 'stderr'
logging_collector = on
log_filename = 'postgresql-%Y-%m-%d.log'
External sinks (syslog, Splunk, Datadog) can filter on AUDIT: from the standard
log stream without any extension-level configuration.
SQL Functions
| Function | Returns | Description |
|---|---|---|
pg_vault_tde_health_check() |
composite | Status (5 columns: version, enabled, kms_provider, enc_ops_available, checked_at) |
pg_vault_tde_verify_integrity(regclass) |
record | GCM tag audit scan of all tuples — returns (total_tuples, failed_tuples) |
pg_vault_tde_encrypted_size(regclass) |
record | Encryption storage overhead — returns (total_tuples, encryption_overhead_bytes) |
pg_vault_tde_reencrypt_table(regclass, int) |
void | Batch re-encrypt with current DEK (locks table); int = batch size, default 1000 |
pg_vault_tde_rotate_online(regclass, int) |
void | BGW-based online rotation, no exclusive lock; accepts both encrypted_heap tables and tde_btree indexes (v1.5) |
pg_vault_tde_get_rotation_status(regclass) |
table | Online rotation progress for one relation (status, tuples_done/total, pct_complete, timestamps) (v1.5) |
pg_vault_tde_rotation_status |
view | All in-progress/completed rotations across the cluster; readable by pg_monitor (v1.5) |
pg_vault_tde_check_plaintext_index_keys() |
table | Lists tde_btree indexes still using a pre-v1.6 plaintext operator class, with a ready-to-run REINDEX suggestion; pg_monitor/superuser only |
pg_vault_tde_wallet_init(text) |
void | Create local wallet and generate KEK (v1.5) |
pg_vault_tde_wallet_change_passphrase(text, text) |
void | Re-protect wallet with new passphrase and automatically rotate the KEK (local provider only); no separate rotate_kek() needed (v1.6) |
pg_vault_tde_wallet_status() |
composite | Wallet existence, open state, algorithm, last opened, file perms (5 cols) (v1.6) |
pg_vault_tde_wallet_unlock(text) |
void | Interactive wallet unlock without PG restart (v1.6) |
pg_vault_tde_wallet_lock() |
void | Evict all DEKs from shmem, mark wallet closed (v1.6) |
pg_vault_tde_rotate_kek() |
void | Rotate the KEK and re-wrap all per-table DEKs under a new key; works for the local, vault, and pkcs11 providers; no tuple data re-encrypted (v1.7) |
pg_vault_tde_pkcs11_keygen() |
void | One-time AES-256 KEK provisioning on the PKCS#11 token under pkcs11_key_label; refuses to overwrite an existing key (v1.7) |
pg_vault_tde_seal_keys(text, text, text) |
void | Write an HMAC-SHA256-signed bundle of all wrapped DEKs (every provider) to a file, to accompany a physical backup (pg_basebackup); the KEK is never included (v1.7) |
pg_vault_tde_seal_keys_bytea(text, text) |
bytea | Same signed bundle as pg_vault_tde_seal_keys(), returned as bytea instead of written server-side — used by pg_basebackup_tde to store the bundle on the client host (v1.7) |
pg_vault_tde_unseal_keys(text, text) |
void | Verify (HMAC) and re-import a bundle written by pg_vault_tde_seal_keys(); rejects a tampered file or wrong passphrase before writing anything (v1.7) |
pg_vault_tde_migrate_vault_to_wallet(text) |
void | Online Vault→local wallet migration (v1.6) |
pg_vault_tde_vault_status() |
table | Vault provider diagnostics — (configured, auth_method, reachable) |
pg_vault_tde_refresh_token() |
boolean | Manually renew the current Vault token lease |
pg_vault_tde_hw_accel_info() |
record | OpenSSL provider/cipher diagnostics — (openssl_version, configured_provider, provider_loaded, gcm_cipher, siv_cipher, aes_ni_available) |
Access Methods
| Name | Type | Purpose |
|---|---|---|
encrypted_heap |
TABLE | Encrypts all user-data columns of every stored tuple |
tde_btree |
INDEX | AES-256-SIV deterministic encryption for B-Tree index keys |
-- Table with encrypted heap storage
CREATE TABLE secrets (id serial, token text) USING encrypted_heap;
-- B-Tree index with deterministic key encryption
CREATE INDEX ON secrets USING tde_btree (id);
Compatibility
| Feature | Status | Notes |
|---|---|---|
| Sequential scan | ✅ Full | scan_getnextslot override |
| Index scan | ✅ Full | index_fetch_tuple override + rd_tableam impersonation |
| Bitmap heap scan | ✅ Full | scan_bitmap_next_tuple override |
| ANALYZE | ✅ Full | scan_analyze_next_tuple override |
| TABLESAMPLE | ✅ Full | scan_sample_next_tuple override |
| SELECT FOR UPDATE | ✅ Full | tuple_lock override |
| INSERT / COPY | ✅ Full | tuple_insert + multi_insert override |
| UPDATE | ✅ Full | tuple_update override + ctid preservation |
| DELETE | ✅ Full | No-op (heapam header-only delete, no column data touched) |
| HOT chains | ✅ Full | Header plaintext → HOT chain pointers preserved |
| VACUUM | ✅ Full | Inherited from heapam (dead-tuple header only) |
| CTAS | ✅ Full | Per-table DEK registration before SELECT is executed |
pg_dump (plain) |
⚠️ Dump is plaintext | pg_dump reads via scan_getnextslot → decrypted. Use pg_dump_tde to re-encrypt the output. |
pg_dump_tde / pg_restore_tde |
⚠️ Full, except pkcs11 |
Encrypted logical backup: dump wrapped with AES-256-GCM + DEK sealed in backup header. Standalone tools have no PKCS#11 session/PIN handling yet — see “Configure Key Access → PKCS#11 / HSM” above. |
| Streaming replication | ✅ Full | WAL ships encrypted bytes; standby decrypts at TAM layer |
| Page checksums | ✅ Full | Checksums over encrypted content (complementary to GCM) |
| Logical replication (non-TOAST) | ✅ Full (v1.2) | pg_vault_tde_pgoutput plugin decrypts tuples before streaming |
| TOAST (large values > ≈2 kB) | ✅ Full | Heap-level round-trips functional; per-chunk storage encryption |
| Logical replication (TOAST columns) | ✅ Full (v1.7) | Custom WAL rmgr (toast_custom_rmgr) routes encrypted chunks past the reorder buffer; stitched in change_cb. UPDATE/DELETE need REPLICA IDENTITY FULL + PK |
| Range scans on TDE indexes | ⚠️ By design | tde_btree (GIN/Hash/GiST planned for v1.8, same AES-SIV pattern) — equality only; ranges return empty |
CREATE INDEX USING gin/gist/hash/brin/btree on encrypted_heap |
⚠️ ERROR by default |
Not encrypted AMs; rejected unless pg_vault_tde.allow_plaintext_index = on (then allowed with WARNING) |
| Column-level encryption | 🔜 v1.8 | Per-column ENABLE COLUMN ENCRYPTION DDL |
Testing
# Full local CI pipeline (build + all tests + bench):
make ci-all
# Test against a specific PG version:
PG_VERSION=17 make ci-all
# Individual test stages:
make ci-regress # 109 SQL regression tests (vault provider) — tests 1-109 (test 110 deferred)
make ci-wallet # 109 SQL regression tests (local wallet provider)
make ci-checksums # 109 tests + page checksum compatibility
make ci-tap # TAP tests with mock Vault
make ci-isolation # Concurrency / MVCC isolation tests
make ci-vault # Vault integration (Compose-based)
make ci-openbao # OpenBao Raft 3-node HA integration (12 tests)
make ci-bench # Performance benchmark (encrypted vs plain heap)
make ci-bench BENCH_ROWS=100000 # with custom row count
# Cleanup:
make ci-clean # Remove test containers and images
Test coverage (109 tests = 52 v1.4 + 20 v1.5 + 37 v1.6):
- Tests 1-11: AES-256-GCM crypto primitives, DEK rotation, tamper detection
- Tests 12-14: TAM INSERT/SELECT/UPDATE end-to-end
- Test 15: DELETE
- Test 16: All-NULL rows (zero-length user data)
- Test 17: Index scan (index_fetch_tuple path)
- Test 18: COPY/bulk insert (multi_insert path)
- Test 19: Multi-column table (int, text, bool, numeric, timestamptz)
- Test 20: Key rotation isolation (DEK-A rows rejected by DEK-B)
- Test 21: ANALYZE produces correct statistics on decrypted data
- Test 22: SELECT FOR UPDATE (tuple_lock path)
- Test 23: BitmapHeapScan (scan_bitmap_next_tuple path)
- Test 24: TABLESAMPLE (scan_sample_next_tuple path)
- Tests 25-48: UPSERT, MERGE, TRUNCATE, REINDEX, ALTER, JOINs, CTEs, HW accel, Vault, logical decoding
- Test 49: Wire format v2 round-trip (version byte + generation counter) (v1.4)
- Test 50: tde_btree CREATE INDEX + equality index scan (v1.4)
- Test 51: health_check() kms_provider column coherence with GUC (v1.6 realignment)
- Test 52: tde_btree UNIQUE constraint (v1.4)
- Tests 53-56: Per-table DEK catalog, wallet SQL stubs, rotation progress schema (v1.5)
- Tests 57-61: TOAST large-value round-trips (4 kB text, 8 kB jsonb, UPDATE, bulk COPY, raw-page check) (v1.5)
- Tests 62-64: Per-table DEK isolation (two tables; DEK-A cannot decrypt table-B), DROP TABLE catalog cleanup (v1.5)
- Tests 65-67: tde_btree native type operator classes (text, int4, uuid) (v1.5)
- Tests 68-69: Wire format v3 AEAD AAD — cross-table paste attack rejected (v1.5)
- Tests 70-72: Online key rotation BGW — concurrent SELECTs, progress tracking, BGW completion (v1.5)
- Tests 73-77: Wallet provider — init/unlock/lock, wallet_status() 5-col schema (wallet_exists, wallet_open, kek_algorithm, last_opened, file_perms), DEK round-trip with wallet KEK (v1.6)
- Tests 78-79: Wallet change_passphrase re-wraps under new KEK; rotate_kek re-wraps all per-table DEKs (catalog ciphertext changes; both tables remain readable) (v1.6 patch)
- Test 81: DDL hook registers BOTH parent and reltoastrelid in pg_vault_tde_catalog; DROP deregisters both (v1.6)
- Test 82: 64 KB compressible payload (pglz keeps it inline) — heap-level pre-TOAST + encrypt round-trip (v1.6)
- Test 83: Transactional rollback after pre-TOAST + encrypt keeps the table consistent and restores reltoastrelid (v1.6)
- Test 84: pg_vault_tde_verify_plaintext_on_disk() on STORAGE EXTERNAL payload — forensic helper confirms ciphertext on disk (v1.6)
- Test 85: STORAGE EXTERNAL round-trip — incompressible 80 KB payload produces real TOAST chunks (~41 in pg_toast_NNN); validates the TAM RELKIND_TOASTVALUE read-path bypass that allows plaintext chunks to round-trip through the encrypted parent table (v1.6 patch)
- Test 86: pg_vault_tde_verify_plaintext_on_disk() on STORAGE EXTERNAL incompressible payload (real TOAST chunk path; test-only helper, requires pg_vault_tde.dev_mode=on) (v1.6 patch)
- Test 87: pg_vault_tde_verify_toast_by_comparison() byte-for-byte TOAST forensic helper on STORAGE EXTERNAL incompressible payload (test-only helper, requires pg_vault_tde.dev_mode=on) (v1.6 patch)
- Test 88: STORAGE EXTERNAL — no compression, real TOAST chunks, plaintext visible via SELECT but absent on disk (v1.6)
- Test 89: STORAGE EXTENDED — compression + TOAST chunks + transparent DML (v1.6)
- Test 90: Storage metadata sanity — attstorage flags and TOAST presence (v1.6)
- Test 91: STORAGE EXTERNAL DELETE removes visible TOAST entries (v1.6)
- Test 92: VACUUM FULL on plain encrypted_heap table (v1.6)
- Test 93: VACUUM FULL on encrypted_heap table with TOAST data (v1.6)
- Test 94: CLUSTER on encrypted_heap with TOAST data (v1.6)
- Test 95: TOAST data readable via index scan (index_fetch_tuple) (v1.6)
- Test 96: TOAST data readable via BitmapHeapScan (v1.6)
- Test 97: TOAST data readable via SELECT FOR UPDATE (tuple_lock) (v1.6)
- Test 98: TOAST data readable via TABLESAMPLE (scan_sample_next_tuple) (v1.6)
- Test 99: TOAST data — ANALYZE computes statistics correctly (v1.6)
- Test 100: multi_insert (COPY path) with TOAST-triggering values (v1.6)
- Test 101: Multi-column TOAST — two large varlena attributes (v1.6)
- Test 102: UPDATE large→large exercises old_has_external branch in tuple_update (v1.6)
- Test 103: pg_vault_tde.toast_encryption=on — TOAST table uses encrypted_heap AM (pg_vault_tde_toast_am callback) (v1.6)
- Test 104: TOAST header overflow edge case (v1.6)
- Test 105: ALTER TABLE x SET ACCESS METHOD heap — convert encrypted to plain heap (v1.6)
- Test 106: ALTER TABLE x SET ACCESS METHOD encrypted_heap — convert plain heap to encrypted (v1.6)
- Test 107: Tuple readable after pg_vault_tde_rotation_online() completes (v1.6)
- Test 108: CREATE TABLE AS with encrypted_heap (v1.6)
- Test 109: VACUUM FULL on table with STORAGE EXTERNAL columns (v1.6)
Test runner notes: -
make ci-regress(vault provider): 109/109 PASS, with conditional skips forwal_level(test 48),pageinspect(test 61) and wallet-only assertions (tests 74–80 whenkms_provider=localis required). -make ci-wallet(local provider): tests 73–79 PASS; test 80 SKIPS unlesswallet_passphrase_envis wired up; tests 81–109 also PASS in wallet mode. - Test 110 (WITH HOLD cursor plaintext spill) is permanently deferred — the executor’s tuplestore layer bypasses the TAM write path, so pg_vault_tde cannot intercept it without core modifications. The test is commented out inregression_test_v16.sql.
Building
make && sudo make install
# Optional: -O3 -funroll-loops -fomit-frame-pointer instead of -O2
make TDE_OPTIMIZE=max && sudo make install
There is a single build. Hardware-accelerated AES (AES-NI, VAES, ARM Crypto
Extensions, SVE2) is provided automatically at runtime by OpenSSL’s own
default provider, based on the CPU the server is actually running on — this
requires no special compiler flags and no separate build. pg_vault_tde never
implements AES itself; it always calls into OpenSSL’s EVP API
(src/crypto/pg_vault_tde_hw_accel.c), which does its own CPUID/HWCAP
detection independent of how pg_vault_tde.so was compiled. Confirm what’s
actually active at runtime with:
make check-cpu # detect this machine's available CPU crypto extensions
make bench-cpu # OpenSSL AES throughput microbenchmark
SELECT * FROM pg_vault_tde_hw_accel_info();
Packages
The easiest way — no local build toolchain required (only podman or docker):
# Build all four packages (deb+rpm × pg17+pg18) into ./dist/
bash packaging/build_in_container.sh --all
# Single package (defaults: DEB, PG18, Ubuntu 22.04)
bash packaging/build_in_container.sh
bash packaging/build_in_container.sh --format rpm # RPM PG18
bash packaging/build_in_container.sh --pg-version 17 # DEB PG17
bash packaging/build_in_container.sh --format rpm --pg-version 17 # RPM PG17
OS version selection
Choose the base OS image for the build container:
# DEB — Ubuntu or Debian
bash packaging/build_in_container.sh --os-version ubuntu:22.04 # default (Jammy LTS)
bash packaging/build_in_container.sh --os-version ubuntu:24.04 # Noble LTS
bash packaging/build_in_container.sh --os-version debian:12 # Bookworm
bash packaging/build_in_container.sh --os-version debian:11 # Bullseye
# RPM — Rocky Linux or AlmaLinux (EL-compatible)
bash packaging/build_in_container.sh --format rpm --os-version rockylinux:9 # default (EL9)
bash packaging/build_in_container.sh --format rpm --os-version rockylinux:8 # EL8
bash packaging/build_in_container.sh --format rpm --os-version almalinux:9 # EL9 (AlmaLinux)
bash packaging/build_in_container.sh --format rpm --os-version almalinux:8 # EL8 (AlmaLinux)
Options compose freely:
# RPM for PG17 on Rocky Linux 8
bash packaging/build_in_container.sh \
--format rpm --pg-version 17 --os-version rockylinux:8
# DEB for PG18 on Debian 12
bash packaging/build_in_container.sh --os-version debian:12
If you have a local build environment, invoke the underlying scripts directly:
# Debian / Ubuntu
bash packaging/build_deb.sh --no-sign
# RHEL / Rocky / Fedora
bash packaging/build_rpm.sh
Encrypted Backups
pg_dump_tde / pg_restore_tde
Plain pg_dump decrypts rows at read time (via the TAM), so the dump file is
plaintext. pg_dump_tde closes this gap by piping the dump through
AES-256-GCM before touching disk:
# Encrypted dump
pg_dump_tde -h localhost -U postgres -d mydb -o /backup/mydb.tde
# Restore encrypted dump
pg_restore_tde -h localhost -U postgres -d mydb -i /backup/mydb.tde
All other
pg_dumpoptions are fed directly to it.
pg_basebackup
It’s possible to use pg_basebackup to create a base backup of the cluster and use it for a standby creation.
Primary configuration
No special configuration is needed on the primary: encrypted relations are copied as-is by pg_basebackup, and the wrapped DEKs travel inside pg_vault_tde_catalog (part of the data directory). The KEK never travels with the backup — it stays in the KMS/wallet, exactly as with Oracle RMAN, SQL Server and Percona pg_tde.
Standby / restore configuration
The wrapped DEKs arrive with the base backup, but the KEK must be made available on the target separately:
localprovider — copy the primary’swallet.p12to the standby (it lives outsidePGDATA, so it is not in the base backup).vaultprovider — point the standby at the same Vault; nothing to copy.
Sealing the DEK catalog (key sealing)
pg_vault_tde_seal_keys() writes a signed, point-in-time snapshot of every wrapped DEK to accompany the backup pg_vault_tde_unseal_keys() verifies and re-imports it on the target. This makes the key state tamper-evident and guards against key-rotation drift between primary and standby.
-- On the primary, before pg_basebackup:
SELECT pg_vault_tde_seal_keys('/backup/keys.sealed', 'a-seal-passphrase');
pg_basebackup -h primary -D /backup/data -X stream
# local provider only: also transport the wallet, e.g.
# scp /path/to/wallet.p12 standby:/path/to/wallet.p12
-- On the standby, after restoring the data dir and providing the KEK:
SELECT pg_vault_tde_unseal_keys('/backup/keys.sealed', 'a-seal-passphrase');
The HMAC key is derived from the seal passphrase (PBKDF2-SHA256); it isindependent of the KMS provider, so the same bundle works for local and vault. unseal_keys verifies the HMAC before touching the catalog: a tampered bundle or wrong passphrase is rejected and nothing is written.
Key-rotation note: if the KEK/DEK is rotated after a backup, primary and standby can drift. Re-running seal_keys after a rotation (and unseal_keys on the standby) realigns the sealed key state with the data.
Concurrency note: don’t run
unseal_keys()whilepg_vault_tde_rotate_online()is rotating the same table. Postgres’s own MVCC checks make this fail safely — you’ll see atuple concurrently updatedor duplicate-key error and nothing will have been imported — just re-rununseal_keys()once the rotation finishes.
pg_basebackup_tde (automatic key sealing)
pg_basebackup_tde wraps pg_basebackup and performs the sealing step
automatically, for every database in the cluster that has the extension
(the DEK catalog is per-database, while pg_basebackup is cluster-wide):
# passphrase from a 0600 file (the ~/.pgpass pattern) ...
pg_basebackup_tde -h primary -D /backup/data -X stream \
--seal-passphrase-file /etc/pg_vault_tde/seal.pass
# ... or from the environment
export PG_VAULT_TDE_SEAL_PASSPHRASE='a-seal-passphrase'
pg_basebackup_tde -h primary -D /backup/data -X stream
The passphrase is never accepted as a command-line value: it would leak in
ps output and shell history. --seal-passphrase-file reads the first line
of the file and takes precedence over the environment variable.
All options are forwarded verbatim to pg_basebackup. For each database with
pg_vault_tde, the wrapper calls pg_vault_tde_seal_keys_bytea() before
the backup starts (point-in-time key snapshot) and, only if the backup
succeeds, writes one bundle per database next to it:
/backup/data/pg_vault_tde_keys.<datname>.sealed (mode 0600)
Use --keys-dir DIR to store the bundles elsewhere (e.g. outside PGDATA).
Databases without the extension are skipped; a failed backup leaves no bundle
files behind. The tar format (-Ft) is not supported — use the plain format
or run pg_vault_tde_seal_keys() manually.
Restore stays manual, exactly as above: restore the data dir, provision the
KEK, then per database
SELECT pg_vault_tde_unseal_keys('/backup/data/pg_vault_tde_keys.<db>.sealed', '...');
How it works
pg_dump_tdeforkspg_dump -Fcwith stdout redirected to a pipe.- It connects to PostgreSQL to read
pg_vault_tde.kms_providerfrom GUCs. - Generates a fresh DEK, wraps it via the active KMS provider, writes a
tde_backup_header(magic + format_version + wrapped_dek) to the output file. Reads the
pg_dumpstream in 64 KB blocks; encrypts each block as:[ Block length (4) | 0x02 (1) | IV (12) | Ciphertext | GCM-TAG (16) ]Block sequence number is bound as GCM AAD — reordering blocks is detectable.
(Reading from a stream with
freadnot guarantee that the block is 64 KB every time, that’s why the block length is stored)- If
pg_dumpfails mid-stream the partial output file is deleted automatically.
Block wire format
[ tde_backup_header ]
[ Block 0: Block length (4) | 0x02 | IV(12) | CT(64 KB) | TAG(16) ]
[ Block 1: Block length (4) | 0x02 | IV(12) | CT(64 KB) | TAG(16) ]
...
Each block is independently authenticated — corruption is detected at the block level, not only at EOF.
Current limitations
Only
-Fcformat is supported.-joption is NOT supported. Parallel jobs are only supported bypg_dumpif the directory format (-Fd) is set.Fixed block size: 64 KB.
Restore is locked to the original KEK used for DEK wrapping. This means that if we need to restore a dump into a new database that is using a different wallet (KMS local speaking) from the original, we can’t. The old wallet or a new wallet containing the old KEK is needed.
Currently (v1.7) deleting a database (
DROP DATABASE) deletes his .p12 wallet file. Dump files previous created from this database becomes undecryptable (if wallet file is lost).File-only output and input. The option
--outputor-o(forpg_dump_tde) and--inputor-i(forpg_restore_tde) are mandatory. Neither piping nor reading fromstdinare supported.
6. Executing pg_dump still produces a plain-text backup
Performance
Overhead vs Plain Heap
pg_vault_tde adds AES-256-GCM encryption/decryption and IV generation on every tuple read and write. The expected overhead depends on workload and row size:
| Workload | Typical Overhead | Notes |
|---|---|---|
| OLTP (mixed R/W, 100–500 B rows) | < 15% | Target budget per copilot-instructions |
| Bulk INSERT (1M rows) | 25–40% | AES-GCM + pg_strong_random per tuple |
| Sequential scan (1M rows, read-only) | 20–35% | Decrypt + palloc copy per tuple |
| Index scan (point lookups) | < 5% | Single tuple decrypt per fetch |
Buffer Pin Behaviour
decode_slot copies the encrypted tuple from the shared buffer page and decrypts
it into a palloc’d plaintext tuple. The shared buffer pin is held until
ExecForceStoreHeapTuple() releases it internally — this preserves the
page-at-a-time access pattern of heapam’s sequential scan. Buffer hit counts
for encrypted tables should be comparable to plain heap (proportional to the
number of pages, not rows).
Limitations (v1.7)
See doc/ROADMAP.md for the full gap-closure roadmap.
tde_btree fixed-size type index key encryption — ✅ Resolved in v1.7:
int4,int8,uuid,date,timestamptzcolumns now have their btree index keys encrypted with AES-256-SIV, identical to varlena types. Index-only scans are not supported (by design, for security — seedoc/pg_vault_tde.md§ Index-Only Scans).Range scans on TDE indexes (by design — permanent): The
tde_btreeAM uses AES-256-SIV (equality-preserving, NOT order-preserving).WHERE col > 'x'on atde_btreeindex returns empty results. Use sequential scans for range predicates.Logical replication of TOAST columns (✅ resolved in v1.7): Enable
pg_vault_tde.toast_custom_rmgr(PGC_POSTMASTER, default off) to publish externally-TOASTed columns to subscribers. UPDATE/DELETE requireREPLICA IDENTITY FULLand a primary key;REPLICA IDENTITY DEFAULTand PK-less tables remain unsupported (the replica identity would be read from ciphertext). See doc/pg_vault_tde.md → “Logical Decoding and Replication”.All-or-nothing table encryption (→ v1.8): All columns in an
encrypted_heaptable are encrypted. Per-columnENABLE COLUMN ENCRYPTIONDDL is planned for v1.8.WAL unencrypted (permanently deferred): Full WAL encryption requires a hook in
XLogInsert()/XLogWrite()— not achievable as a PostgreSQL extension.WITH HOLDcursor temporary file is unencrypted (permanently deferred): PostgreSQL materializes aCURSOR WITH HOLD’s entire result set into a tuplestore when the declaring transaction commits, so the cursor can still be fetched from afterward. Once that result set exceedswork_mem, the tuplestore spills to a temporary file on disk, and that file is written in plaintext. The tuplestore is populated directly by the executor, bypassing the table access method write path entirely, sopg_vault_tdenever gets a chance to encrypt the data before it reaches disk — there is no extension hook anywhere in theWITH HOLDcursor lifecycle (parse, plan, portal start, commit-time persist) that can intercept it. This is the same class of gap documented for other TDE implementations (e.g. Percona’spg_tde): temporary files produced by query execution that exceedwork_memare not covered by table-level encryption. The spilled file can outlive the query that created it — it persists for as long as the held cursor remains open, and, like any other PostgreSQL temp file, is not guaranteed to be cleaned up if the server crashes before the owning session ends normally.Mitigation: set
work_memlarge enough that cursor result sets are expected to stay in memory, and avoid declaringWITH HOLDcursors over queries that touchencrypted_heaptables (directly or through a view) in memory-constrained environments or wherever the result set size can’t be bounded in advance.HOT updates are disabled by design (so that updating an indexed column always maintains the index): On an
encrypted_heaptableheap_updatenever chooses a HOT (heap-only) update — every UPDATE writes new index entries, keepingtde_btreeindexes coherent without aREINDEX. How:heap_updatedecides whether an update is HOT by comparing the indexed columns byte-for-byte between the old and new tuple. Both tuples are encrypted, and the v4 wire format is IV-first: it begins with the random GCM IV, which changes on every encryption. The encrypted image therefore always differs, soheap_updatesees the indexed column as modified and skips the HOT path. The constant[VERSION | GENERATION]bytes were moved to the end of the blob precisely so they fall outside the comparison window. See doc/pg_vault_tde.md § Known Limitations for the full analysis (including the v3 bug this resolved).Parallel index build/rebuild is disabled by design: the parallel workers that PostgreSQL uses to build or rebuild an index run in separate processes that are not intercepted by the TAM/IAM wrappers, so a parallel worker would read raw ciphertext as if it were plaintext. This is disabled via
amcanbuildparallel = falseontde_btree.Only
tde_btreeis an encrypted index AM (→ v1.8 for GIN/Hash/GiST):CREATE INDEX/CREATE UNIQUE INDEX USING gin/gist/hash/brin/btreeagainst anencrypted_heaptable is rejected withERRORby default, because none of those access methods encrypt the key they store — onlytde_btree(AES-256-SIV) does. Setpg_vault_tde.allow_plaintext_index = onto allow it anyway (with aWARNING) when you need trigram/full-text/spatial search or a plain range-scan index on an encrypted table and have accepted that the indexed values will sit in plaintext on disk in that one index. This has caught out users trying to build aPRIMARY KEY/UNIQUEindex as two separate steps (CREATE UNIQUE INDEX ... USING btreethenALTER TABLE ... ADD CONSTRAINT ... USING INDEX, the pattern used withCREATE INDEX CONCURRENTLY): the first statement fails outright, so the table ends up with no index at all — not a broken one — and duplicate inserts go through unblocked simply because there is nothing left to enforce them.PRIMARY KEY/UNIQUEdeclared as a normal table constraint (inline inCREATE TABLE, orALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY (col)withoutUSING INDEX) is unaffected by this setting and always works — PostgreSQL core forces those onto a native btree index regardless, so pg_vault_tde can only warn about it, never block it.Plain
COPY ... TO/pg_dumpproduce a plaintext dump, with no warning (→ v1.8): encryption inpg_vault_tdelives entirely in the table access method’s read callbacks (scan_getnextslotand friends), which decrypt unconditionally and have no way to tell aSELECTapart from aCOPY <table> TO ...— both dispatch through the sametable_scan_getnextslot()call.pg_dump’s default table-data path is exactly this form ofCOPY ... TO stdout, so a plainpg_dump(or a manualCOPY sensitive_table TO '/path') on anencrypted_heaptable silently returns fully decrypted rows — there is currently noProcessUtility_hookguard or GUC-gatedWARNINGfor this (a “dump plaintext warning” was designed but never implemented). Mitigation: always usepg_dump_tde/pg_restore_tdeinstead of plainpg_dump/pg_restorefor logical backups of encrypted tables — see Encrypted Backups.
License
BSD License (PostgreSQL License) — see LICENSE.
Compatible with MIT, BSD, ISC, and Apache 2.0. Not derived from any GPL- or AGPL-licensed code.
Copyright
Copyright © 2026 Miriade S.r.l.