PostgreSQL Hardening
A practical baseline for a single Postgres instance (self-hosted or managed) that backs a web app. Skews toward small-team realities — not a regulated-environment DBA's playbook.
When to invoke
- New Postgres provisioned (Docker, package, RDS, Supabase, Neon — same threat model)
- Inheriting an existing Postgres with no documented hardening
- Adding a new application or service that connects to an existing Postgres
- After a Postgres security advisory
- Reviewing a multi-tenant schema where one tenant should not see another's data
- Planning a major-version upgrade (14 → 15 → 16 → 17)
Step 1 — Network reachability
Postgres listening on 0.0.0.0:5432 open to the internet is one of the most common misconfigurations in shared/cloud environments.
# What is the listener bound to?
sudo ss -tlnp | grep 5432
# From outside the VPS — can the world reach it? (this should fail)
nc -zv <vps-ip> 5432
Acceptable bindings:
127.0.0.1only — app on same host, no remote access (best for single-VPS deployments)- VPC-internal IP only — apps in same private network
- Public IP only with TLS-required + IP allowlist at firewall — last resort, document why
In postgresql.conf:
listen_addresses = 'localhost' # or 'localhost,10.0.0.5' for private LAN
port = 5432
Restart Postgres after change.
Step 2 — pg_hba.conf (host-based auth)
This file is the primary access-control surface. Order matters: the first matching rule wins.
# TYPE DATABASE USER ADDRESS METHOD
# Local Unix socket — for admin tasks
local all postgres peer
local all all scram-sha-256
# App connections from same host
host app_db app_user 127.0.0.1/32 scram-sha-256
host app_db app_user ::1/128 scram-sha-256
# Private LAN, TLS required
hostssl app_db app_user 10.0.0.0/24 scram-sha-256
# Read-only analytics user, scoped
hostssl app_db readonly_user 10.0.0.0/24 scram-sha-256
# Catch-all reject — explicit deny at the end
host all all 0.0.0.0/0 reject
host all all ::/0 reject
Auth method recommendations:
scram-sha-256for passwords (replaces oldermd5). Force this inpostgresql.conf:password_encryption = scram-sha-256peerfor thepostgressuperuser on the local socket — convenient and safecert(client certificate) for service-to-service when feasible- Never
trustoutside a freshly-isolated provisioning step
Apply: SELECT pg_reload_conf();
Step 3 — Role separation
Default deployments use one all-powerful user for the app. Split the responsibilities:
-- Application owner — owns schema, runs migrations (used by CI / deploy, not the running app)
CREATE ROLE app_owner LOGIN PASSWORD :'pw_owner';
GRANT CONNECT ON DATABASE app_db TO app_owner;
GRANT CREATE ON DATABASE app_db TO app_owner;
-- Running app — read/write data only, no schema changes, no superuser anywhere
CREATE ROLE app_user LOGIN PASSWORD :'pw_app';
GRANT CONNECT ON DATABASE app_db TO app_user;
-- After schema is in place:
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
-- Read-only — analytics, BI tools, AI agents reading prod
CREATE ROLE readonly_user LOGIN PASSWORD :'pw_ro';
GRANT CONNECT ON DATABASE app_db TO readonly_user;
GRANT USAGE ON SCHEMA public TO readonly_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO readonly_user;
Benefits:
- An app SQL-injection cannot drop tables (only the migration role can)
- Analytics / LLM agents get
readonly_user— they cannot accidentally mutate prod - Credential rotation per role is independent
Step 4 — Row-Level Security for multi-tenant data
If multiple tenants share tables, RLS makes tenant-isolation enforced by the database, not just the app.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- applies even to table owner
CREATE POLICY invoice_tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- In the app's connection setup, per request:
-- SET LOCAL app.current_tenant = '<uuid>';
Watch for:
BYPASSRLSattribute on any role except a specific maintenance role — auditSELECT rolname FROM pg_roles WHERE rolbypassrls;- Policies that only have
USINGand noWITH CHECK—INSERTmay bypass the intended row constraint - Connection pooling: if the same connection is reused across tenants,
SET LOCALdiscipline is mandatory; otherwise tenant data leaks across users
Step 5 — TLS
# postgresql.conf
ssl = on
ssl_cert_file = '/etc/ssl/postgres/server.crt'
ssl_key_file = '/etc/ssl/postgres/server.key'
ssl_min_protocol_version = 'TLSv1.2'
In pg_hba.conf use hostssl (not just host) for any non-localhost entry. Test from a client:
psql "host=db.example.com user=app_user dbname=app_db sslmode=verify-full sslrootcert=/path/to/ca.pem"
Client-side: app connection strings should set sslmode=verify-full in production — require (the common copy-paste) does not validate the cert.
Step 6 — Backup + encryption
The backup story is part of the security story. A leaked plaintext dump is the same as a leaked database.
# Daily dump, gzip + age-encrypt before leaving the host
pg_dump -Fc app_db | gzip | age -r <recipient-public-key> > /backups/app_db-$(date +%F).sql.gz.age
# Upload encrypted backups to off-site storage (S3/R2/B2)
- Encrypt before the data leaves the host
- Test restore quarterly — a backup you have not restored is a wish, not a backup
- Retain a chain: daily 7 days, weekly 4 weeks, monthly 12 months — tune to your tolerance
Step 7 — Audit logging
Built-in logging is good enough for most teams; pg_audit adds structured per-statement detail when needed.
# postgresql.conf
log_destination = 'stderr'
logging_collector = on
log_directory = '/var/log/postgresql'
log_filename = 'postgresql-%Y-%m-%d.log'
log_rotation_age = 1d
log_rotation_size = 100MB
log_min_duration_statement = 500ms # slow-query log
log_connections = on
log_disconnections = on
log_statement = 'ddl' # log all DDL; 'all' is noisy but useful in regulated envs
log_line_prefix = '%m [%p] %q%u@%d '
For higher detail, install pg_audit:
CREATE EXTENSION pgaudit;
ALTER SYSTEM SET pgaudit.log = 'write, ddl';
SELECT pg_reload_conf();
Forward logs off-host so a compromise cannot wipe its own audit trail.
Step 8 — Version upgrades
- Pin to a major version intentionally; do not let
docker pull postgres:latestdecide for you - Postgres has a 5-year support window per major version — track end-of-life
- Test upgrades on a copy of prod data, not just an empty DB
pg_upgradeis the standard tool;pg_dump/pg_restorefor major version jumps ifpg_upgradenot viable- After an upgrade,
VACUUM ANALYZEthe whole DB before opening to traffic
Step 9 — Common findings on inherited instances
When auditing a Postgres you did not provision, these are the high-frequency hits:
pg_hba.confhas ahost all all 0.0.0.0/0 md5line because some tutorial said so- One
postgressuperuser is used for the running app - TLS is off, or
sslmode=requirewithoutverify-full - No backups, or backups stored next to the database
log_statement = 'all'left on with no rotation — disk fills, then either Postgres or alerts die- A development seed user still exists with a weak password
BYPASSRLSgranted to the app role "for convenience"
Each is a finding; prioritize by exposure.
Quick audit queries
-- Roles with login + superuser
SELECT rolname, rolsuper, rolcreatedb, rolcreaterole, rolbypassrls
FROM pg_roles WHERE rolcanlogin ORDER BY rolname;
-- Tables without RLS where you expect it
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY rowsecurity, tablename;
-- Extensions installed (audit periodically — unfamiliar extensions are suspicious)
SELECT name, installed_version FROM pg_available_extensions WHERE installed_version IS NOT NULL;
-- Recent failed connections (in the log) — set up a log-tail alert
-- grep 'authentication failed' /var/log/postgresql/*.log | tail
What this skill will not do
- Help bypass Postgres authentication on systems you do not own
- Recommend
trustauth orhost all all 0.0.0.0/0outside an isolated provisioning step - Replace a regulated-environment DBA's full hardening guide for HIPAA/PCI/etc.