PostgreSQL Encryption At Rest And In Transit

PostgreSQL’s encryption surface does not map one-to-one to SQL Server’s TDE hierarchy. Core PostgreSQL divides the problem into separate layers: password hashing for authentication, TLS or GSSAPI for transport, optional column-level cryptography through pgcrypto, and filesystem or block-device encryption for the actual cluster files. The operational mistake is to assume one of those layers automatically covers the others.


flowchart TD
  THREAT["Sensitive-data threat"] --> NET{"Network exposure?"}
  NET -->|Yes| TLS["Enable TLS and verify with pg_stat_ssl"]
  NET -->|No| REST{"At-rest exposure?"}
  REST -->|Cluster files / WAL / backups| DISK["Use filesystem or block-device encryption"]
  REST -->|Specific columns only| COL["Use pgcrypto or client-side encryption"]
  REST -->|Server admin not trusted| CLIENT["Keep plaintext off the server with client-side encryption"]

What PostgreSQL Actually Encrypts

PostgreSQL | encryption boundary | separate the four native layers

Match the control to the threat you are actually defending against

Use this model before enabling any setting or extension so the control matches the risk. It is typically triggered during security hardening, audit response, or migration from a platform where “database encryption” meant a single checkbox. The context is architectural rather than command-driven: it is read-only reasoning, but it determines whether later configuration work is useful or wasted. Its purpose is to prevent the common category error where password hashing, TLS, and at-rest protection are treated as interchangeable.

Core PostgreSQL separates the encryption problem this way:

LayerNative PostgreSQL surfaceWhat it protectsWhat it does not protect
Password hashingpassword_encryption = 'scram-sha-256'stored role-password verifiertable data, WAL, backups, network traffic
Transport encryptionTLS / SSL and optionally GSSAPIqueries, credentials, and result sets in transitdata already written to disk
Selective data encryptionpgcrypto or client-side cryptochosen values or columnscluster-wide transparent encryption
At-rest cluster protectionfilesystem or block-device encryptiondata files, WAL, base backups, copied volumesplaintext visible to the mounted host and PostgreSQL process

That is the direct replacement for the SQL Server TDE mental model. The PostgreSQL core documentation’s encryption-options section still describes pgcrypto, TLS, and filesystem or block-level storage encryption rather than a transparent cluster-file encryption feature inside the server itself. The right conclusion is not “PostgreSQL has no encryption”; it is “PostgreSQL expects you to compose the right layers deliberately.”


Baseline The Current Lab Posture

Check the current password, checksum, and TLS posture

Run this before changing certificates, pg_hba.conf, or extension state so the current posture is explicit. It is typically triggered during first-pass hardening, incident review, or platform migration. The command runs inside a PostgreSQL session, is read-only, and requires only the visibility needed to read pg_settings. Its purpose is to prove which encryption-related controls are active right now rather than relying on assumptions from container images or prior notes.

FieldSource columnTypeMeaning
namepg_settings.nametextSetting name being inspected.
settingpg_settings.settingtextCurrent live value as PostgreSQL is using it.
unitpg_settings.unittextUnit for numeric settings when one exists. Blank means the value is symbolic.
contextpg_settings.contexttextConfiguration scope such as internal, user, or sighup. This tells you whether a reload or restart would be required.
sourcepg_settings.sourcetextWhere the value came from: default, configuration file, command line, and so on.

Return the encryption-relevant settings that define the current cluster posture.

SELECT name, setting, unit, context, source
FROM pg_settings
WHERE name IN (
  'data_checksums',
  'password_encryption',
  'ssl',
  'ssl_ca_file',
  'ssl_cert_file',
  'ssl_key_file',
  'ssl_min_protocol_version'
)
ORDER BY name;
namesettingunitcontextsource
data_checksumsoffinternaldefault
password_encryptionscram-sha-256userdefault
sslonsighupconfiguration file
ssl_ca_filesighupdefault
ssl_cert_fileserver.crtsighupdefault
ssl_key_fileserver.keysighupdefault
ssl_min_protocol_versionTLSv1.2sighupdefault

This result is precise about the current boundary:

ColumnValueWatchMeaningImplication
password_encryptionscram-sha-256healthymodern password verifier format is activenew or changed passwords are stored as SCRAM verifiers instead of MD5
sslonhealthyserver is willing to negotiate TLStransport encryption is available, but not necessarily required
ssl_min_protocol_versionTLSv1.2healthyolder TLS versions are excludedclients must negotiate at least TLS 1.2
ssl_ca_fileemptydependsserver is not configured for client-cert trust via CA fileTLS is available, but mutual TLS is not configured here
data_checksumsoffcautionblock checksums are disabledcorruption detection is weaker, and this setting is not an encryption feature anyway

PostgreSQL | pg_hba_file_rules | verify whether TLS is merely available or actually enforced

Read the current host-authentication rules honestly

Run this after confirming that ssl = on so you do not confuse “the server can negotiate TLS” with “all remote clients must use TLS.” It is typically triggered during connection-hardening review or after enabling server certificates. The command is read-only, runs inside PostgreSQL, and exposes the parsed pg_hba.conf rules the server is actually applying. Its purpose is to show whether plain host lines are still accepting non-SSL sessions.

FieldSource columnTypeMeaning
typepg_hba_file_rules.typetextConnection class such as host, hostssl, or hostgssenc.
databasepg_hba_file_rules.databasetext[]Databases the rule applies to.
user_namepg_hba_file_rules.user_nametext[]Roles the rule applies to.
addresspg_hba_file_rules.addresstextClient address or CIDR range matched by the rule.
auth_methodpg_hba_file_rules.auth_methodtextAuthentication method used once the rule matches.

List the current network authentication rules that matter for transport encryption.

SELECT type, database, user_name, address, auth_method
FROM pg_hba_file_rules
WHERE type IN ('host','hostssl','hostgssenc')
ORDER BY line_number;
typedatabaseuser_nameaddressauth_method
host{all}{all}127.0.0.1trust
host{all}{all}::1trust
host{replication}{all}127.0.0.1trust
host{replication}{all}::1trust
host{all}{all}allscram-sha-256

The key signal is the absence of any hostssl rule. TLS is available on this server, but the current pg_hba.conf posture does not force encrypted network sessions. A client can still connect over plain host if it chooses to and if the matched rule allows it. On a hardened production instance, hostssl is the line that turns “available” into “required.”


Encrypt Data In Transit

PostgreSQL | TLS listener and live session proof | verify the transport layer end to end

Confirm the listener is advertising TLS and that clients can prove it

Run this immediately after enabling certificates or changing SSL settings. It is typically triggered by hardening work, client connection failures after certificate changes, or an audit question about whether sessions are really encrypted. The workflow has two parts: a state read from the server and a client connection that requires TLS. The goal is to prove the feature is live at both ends rather than stopping at server-side configuration.

The server was restarted after placing server.crt and server.key in the data directory. After restart, SHOW ssl; returned on and sslmode=require connections to 127.0.0.1:5432 succeeded.

FieldSource columnTypeMeaning
application_namepg_stat_activity.application_nametextClient-supplied identifier used to isolate the test session.
sslpg_stat_ssl.sslbooleant when the backend is currently using TLS.
versionpg_stat_ssl.versiontextNegotiated TLS protocol version.
cipherpg_stat_ssl.ciphertextCipher suite used for this session.
client_dnpg_stat_ssl.client_dntextClient certificate distinguished name if mutual TLS is in use. Blank here means no client cert was presented.
client_addrpg_stat_activity.client_addrinetClient network address connected to the backend.

Open a TLS-required session and verify it from the server side.

SELECT a.application_name,
       s.ssl,
       s.version,
       s.cipher,
       s.client_dn,
       a.client_addr
FROM pg_stat_ssl AS s
JOIN pg_stat_activity AS a
  ON a.pid = s.pid
WHERE a.application_name = 'note13_ssl';
application_namesslversioncipherclient_dnclient_addr
note13_ssltTLSv1.3TLS_AES_256_GCM_SHA384127.0.0.1

This is the clean transport-evidence row the note needed. The session was opened with sslmode=require, PostgreSQL negotiated TLSv1.3, and the cipher suite is a modern AEAD cipher. client_dn is blank because the lab is using server-authenticated TLS, not mutual TLS.


Encrypt Selected Values With pgcrypto

PostgreSQL | pgcrypto | demonstrate selective value encryption

Install the extension and round-trip one encrypted value

Use this when the requirement is “protect a specific value or column” rather than “encrypt every cluster file.” It is typically triggered by sensitive attributes such as API secrets, PAN-like fields, or narrowly scoped compliance rules. The extension install is state-changing and persistent for the database; the demo table below is disposable because it uses a temporary table inside a transaction and rolls back. The purpose is to prove the real column-encryption surface that PostgreSQL exposes inside SQL.

First, the stoxx database was checked for the extension:

CREATE EXTENSION IF NOT EXISTS pgcrypto;
 
SELECT extname, extversion
FROM pg_extension
WHERE extname = 'pgcrypto';
extnameextversion
pgcrypto1.3

Then a disposable round-trip used pgp_sym_encrypt() and pgp_sym_decrypt():

FieldSource columnTypeMeaning
idnote13_crypto_demo.idintegerDemo row identifier.
clear_textnote13_crypto_demo.clear_texttextOriginal plaintext inserted into the temporary table.
cipher_bytesoctet_length(cipher)integerLength of the encrypted bytea payload.
cipher_prefix_hexencode(substring(cipher from 1 for 16), 'hex')textFirst 16 bytes of the ciphertext rendered as hex to prove the stored bytes are opaque.
decrypted_textpgp_sym_decrypt(cipher, 'note13-demo-key')textDecrypted plaintext returned only when the same key is supplied.

Encrypt one value with pgcrypto, show the stored ciphertext shape, then decrypt it in the same disposable transaction.

BEGIN;
 
CREATE TEMP TABLE note13_crypto_demo (
  id int,
  clear_text text,
  cipher bytea
);
 
INSERT INTO note13_crypto_demo
VALUES (
  1,
  'alpha-sensitive',
  public.pgp_sym_encrypt('alpha-sensitive', 'note13-demo-key')
);
 
SELECT id,
       clear_text,
       octet_length(cipher) AS cipher_bytes,
       encode(substring(cipher from 1 for 16), 'hex') AS cipher_prefix_hex,
       public.pgp_sym_decrypt(cipher, 'note13-demo-key') AS decrypted_text
FROM note13_crypto_demo;
 
ROLLBACK;
idclear_textcipher_bytescipher_prefix_hexdecrypted_text
1alpha-sensitive81c30d04070302251cac9e74d55f5f7bd2alpha-sensitive

The operational lesson is straightforward: pgcrypto absolutely works for selected values, but the key and plaintext are still present in the server process while the function runs. That is why pgcrypto is useful for scoped protection and application design, yet it is not a substitute for client-side encryption when the database host itself is outside the trust boundary.


Understand The At-Rest Boundary

PostgreSQL | cluster files and backup artifacts | map the real file boundary

Locate a relation file relative to the data directory

Run this when the question is “what exactly sits on disk if someone copies the cluster volume or a base backup?” It is typically triggered during disk-encryption design, backup threat modelling, or migration from platforms with transparent page encryption. The command is read-only and runs inside PostgreSQL. Its purpose is to connect SQL objects to the physical file tree that PostgreSQL manages so the at-rest boundary is concrete.

FieldSource columnTypeMeaning
relpathpg_relation_filepath('pg_class')textRelative file path for the chosen relation inside the cluster.
data_directorycurrent_setting('data_directory')textCluster root on disk.

Show where a real system relation lives under the data directory.

SELECT pg_relation_filepath('pg_class') AS relpath,
       current_setting('data_directory') AS data_directory;
relpathdata_directory
base/5/1259/var/lib/postgresql/data

That row is the whole at-rest story in one glance: PostgreSQL relations ultimately become files under the cluster directory. WAL, control files, relation forks, and physical base-backup copies live in the same physical world. If an attacker steals the mounted volume or an unencrypted backup copy, core PostgreSQL does not transparently re-encrypt those files for you. The protection layer has to be filesystem or block-device encryption, plus disciplined backup handling.

ArtifactCore PostgreSQL encrypted by default?Practical protection layer
Table and index files under data_directoryNofilesystem or block-device encryption
WAL files under pg_walNofilesystem or block-device encryption
Physical base backupsNoencrypted backup storage or encrypted underlying volume
pg_dump logical exportsNoexternal encryption and controlled storage handling

PostgreSQL Encryption At Rest And In Transit Recommendations

PostgreSQL | production guidance | use the correct layer for the threat

Apply controls in the order that matches operational risk

Use these rules during rollout planning and audit review. They are triggered whenever a team asks for “PostgreSQL encryption” as if it were a single feature request. The context is design and operations rather than a single command. The purpose is to keep transport, credential, and at-rest controls from being mixed together incorrectly.

PriorityControlWhy it comes first
1Require TLS with hostssl where remote clients connectthis closes the clearest network exposure first
2Keep password_encryption = 'scram-sha-256' and retire MD5-era assumptionspassword verifiers should not be the weak link
3Encrypt the volume or filesystem holding data_directory, pg_wal, and backup targetsthis is the actual cluster-file at-rest control
4Use pgcrypto only for selected values that justify the complexityit solves a narrower problem than whole-volume protection
5Move to client-side encryption when the database host itself is not trustedplaintext must never appear on the server in that threat model

Practical rules from the live lab:

ObservationGuidance
ssl = on, but only host rules are presenttransport encryption is available, not enforced
pg_stat_ssl shows TLSv1.3 for note13_sslverify real sessions this way after every certificate change
data_checksums = offdo not confuse corruption detection with encryption; they solve different problems
pgcrypto works cleanly in stoxxgood for scoped secrets, not a stand-in for disk encryption
relation files live plainly under /var/lib/postgresql/dataprotect the storage layer and backup copies explicitly

Next: 14-postgresql-problems turns these controls back into incident language: the concrete PostgreSQL failure patterns that break pipelines, exhaust storage, stall maintenance, or quietly erode recoverability.