Contents
- Known Limitations and Troubleshooting
- Current Limitations (v1.7)
- Troubleshooting
- “GCM tag mismatch” / decryption / integrity errors
- pg_vault_tde_wallet_init() fails / wallet base directory error
- A rotation or unseal_keys() call fails with a concurrency error
- bgw_enabled or max_encrypted_relations change had no effect
- Restore of a pg_dump_tde backup fails after moving to a new server
- A PRIMARY KEY/UNIQUE index seems to silently not exist — duplicate rows go in with no error
- An index still shows up in pg_vault_tde_check_plaintext_index_keys()
- See Also
Known Limitations and Troubleshooting
Current Limitations (v1.7)
| # | Limitation | Status |
|---|---|---|
| 1 | Range scans on tde_btree — WHERE col > x returns empty results (AES-SIV is not order-preserving) |
By design, permanent |
| 2 | HOT updates disabled — every UPDATE on an encrypted_heap table maintains indexes explicitly, never using a HOT update |
By design, permanent (see Encrypted Tables and Indexes) |
| 3 | Parallel index build/rebuild disabled — CREATE INDEX/REINDEX on tde_btree always runs single-process |
By design, permanent |
| 4 | All-or-nothing table encryption — every column in an encrypted_heap table is encrypted; no per-column opt-out |
Planned: per-column ENABLE COLUMN ENCRYPTION DDL (v1.8) |
| 5 | GIN / Hash / GiST index encryption — only tde_btree (B-Tree) exists today |
Planned (v1.8) |
| 6 | pg_statistic stored in plaintext — see Security Considerations |
Planned mitigation (v1.8); REVOKE is today’s workaround |
| 7 | WAL encryption — only tuple payload bytes are encrypted; WAL structural metadata is plaintext, and full WAL encryption would require a core hook (XLogInsert()) unavailable to extensions |
Permanently deferred |
| 8 | BRIN on encrypted columns — min/max of AES-SIV ciphertext is meaningless | By design, permanent |
| 9 | WITH HOLD cursor temp-file spill is unencrypted |
Permanently deferred — see below |
| 10 | PKCS#11 provider unsupported by pg_dump_tde/pg_restore_tde |
Planned; use pg_basebackup_tde meanwhile |
| 11 | CREATE INDEX USING <non-tde_btree> on encrypted_heap is rejected by default — btree, gin, gist, hash, brin all store the key unencrypted |
Escape hatch: pg_vault_tde.allow_plaintext_index = on (see below); real encryption planned (v1.8, item 5) |
| 12 | Plain COPY <table> TO / pg_dump produce a plaintext dump, with no warning — encryption lives entirely in the table access method’s read callbacks, which decrypt unconditionally and can’t distinguish a COPY TO from a SELECT; pg_dump’s default table-data path is exactly this form of COPY |
Use pg_dump_tde/pg_restore_tde instead — see Backup and Restore; a GUC-gated WARNING was designed but never implemented (planned v1.8) |
WITH HOLD Cursor Plaintext Spill — Read Before Relying on Held Cursors
When a transaction holding a WITH HOLD cursor commits, PostgreSQL
materializes the cursor’s entire result set into a tuplestore so it can
still be fetched afterward. If that result set exceeds work_mem, the
tuplestore spills to a plaintext temporary file on disk. This happens
because the executor populates the tuplestore directly, bypassing the table
access method’s write path entirely — there is no extension hook anywhere in
the WITH HOLD cursor lifecycle (parse, plan, portal start, commit-time
persist) that pg_vault_tde can intercept. (This is the same class of gap
documented for other PostgreSQL TDE implementations, including Percona’s
pg_tde.)
The spilled file can outlive the query that created it — it persists for as long as the held cursor stays 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_mem large enough that cursor result sets are expected to stay
in memory for your workload.
- Avoid declaring WITH HOLD cursors over queries that touch
encrypted_heap tables (directly or through a view) in
memory-constrained environments, or wherever the result set size can’t be
bounded in advance.
Troubleshooting
“GCM tag mismatch” / decryption / integrity errors
This means the ciphertext, IV, or associated data did not authenticate —
pg_vault_tde raises an ERROR rather than ever returning a silently wrong
result. Common causes:
- Wrong DEK/KEK — most often, connecting to the wrong database/tenant
configuration, or a wallet/Vault/HSM pointed at the wrong key material
after a restore. Check
SHOW pg_vault_tde.kms_providerand the relevant provider GUCs against what the data was actually encrypted with. - Genuine on-disk corruption — run
SELECT * FROM pg_vault_tde_verify_integrity('mytable');to scope how many tuples are affected, and treat it as a storage/hardware incident. - Cross-table/cross-database ciphertext copied by hand (e.g. a raw
COPYof on-disk bytes from another relation) — the wire format binds each tuple to its own(database, relid, generation)as authenticated data specifically to make this fail loudly instead of silently decrypting as the wrong row.
pg_vault_tde_wallet_init() fails / wallet base directory error
The base directory /var/lib/pg_vault_tde/ must exist, be owned by the OS
user running PostgreSQL, and live outside PGDATA before the wallet can be
created. Package installs create it automatically; source builds do not —
see Installation and KMS: Local Wallet.
A rotation or unseal_keys() call fails with a concurrency error
unseal_keys() and rotate_online() on the same table conflict under
PostgreSQL’s normal MVCC rules (tuple concurrently updated or a
duplicate-key error) rather than corrupting anything. Simply re-run the
failed call once the other operation finishes — see
Key Rotation and Backup and Restore.
bgw_enabled or max_encrypted_relations change had no effect
Both are restart-affecting: bgw_enabled registers a background worker at
postmaster startup (pg_reload_conf() alone will not start/stop it), and
max_encrypted_relations sizes a shared-memory structure allocated once at
startup. Both need a full server restart, not just a config reload — see
GUC Reference.
Restore of a pg_dump_tde backup fails after moving to a new server
Restore is locked to the exact KEK that produced the backup’s wrapped DEK. If the target uses a different local wallet (or the source database — and therefore its wallet — was dropped), restore cannot succeed without the original KEK. See the “Current Limitations” section of Backup and Restore.
A PRIMARY KEY/UNIQUE index seems to silently not exist — duplicate rows go in with no error
This happens when the unique index was built as two separate steps:
CREATE UNIQUE INDEX ... USING btree (col) followed by ALTER TABLE ... ADD
CONSTRAINT ... PRIMARY KEY USING INDEX <name> — the pattern most migration
tools and CREATE INDEX CONCURRENTLY workflows use to avoid a long-held
lock. The first statement is a standalone CREATE INDEX, not a table
constraint, so it hits the index-AM whitelist (limitation #11 above) and is
rejected with ERROR: index access method "btree" is not supported on
encrypted_heap table .... Because it created nothing, the second statement
then fails too (index "..." does not exist). If either error scrolled past
unnoticed — a non-interactive script without ON_ERROR_STOP, a migration
runner that doesn’t halt on error — the table is left with no index and
no constraint at all, and every subsequent INSERT of a “duplicate” key
succeeds, simply because there is nothing left to reject it. This is not a
write-path/TAM bug: the table access method’s INSERT/UPDATE/DELETE
callbacks were never involved, because there was never an index for them to
maintain.
Fix: confirm the index actually exists (\d tablename, or SELECT *
FROM pg_indexes WHERE tablename = '...') before trusting the constraint.
To build it correctly, either use CREATE UNIQUE INDEX ... USING tde_btree
instead of btree, or set pg_vault_tde.allow_plaintext_index = on before
the CREATE UNIQUE INDEX ... USING btree step so it actually succeeds (see
Encrypted Tables and Indexes). A PRIMARY
KEY/UNIQUE declared directly as a table constraint — inline in CREATE
TABLE, or ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY (col) without
USING INDEX — does not have this failure mode: PostgreSQL core always
backs it with a real, working native btree index, and pg_vault_tde only
emits a WARNING about the plaintext exposure, never blocks it.
An index still shows up in pg_vault_tde_check_plaintext_index_keys()
The index was built before v1.6 with a plaintext (non-enc_ops) operator
class on a fixed-size type. The function’s output includes a ready-to-run
REINDEX statement using the encrypted opclass — see
Encrypted Tables and Indexes.