postgres-hardening — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited postgres-hardening (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
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> 5432Acceptable bindings:
127.0.0.1 only — app on same host, no remote access (best for single-VPS deployments)In postgresql.conf:
listen_addresses = 'localhost' # or 'localhost,10.0.0.5' for private LAN
port = 5432Restart Postgres after change.
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 rejectAuth method recommendations:
scram-sha-256 for passwords (replaces older md5). Force this in postgresql.conf: password_encryption = scram-sha-256peer for the postgres superuser on the local socket — convenient and safecert (client certificate) for service-to-service when feasibletrust outside a freshly-isolated provisioning stepApply: SELECT pg_reload_conf();
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:
readonly_user — they cannot accidentally mutate prodIf 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:
BYPASSRLS attribute on any role except a specific maintenance role — audit SELECT rolname FROM pg_roles WHERE rolbypassrls;USING and no WITH CHECK — INSERT may bypass the intended row constraintSET LOCAL discipline is mandatory; otherwise tenant data leaks across users# 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.
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)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.
docker pull postgres:latest decide for youpg_dump/pg_restore for major version jumps if pg_upgrade not viableVACUUM ANALYZE the whole DB before opening to trafficWhen auditing a Postgres you did not provision, these are the high-frequency hits:
pg_hba.conf has a host all all 0.0.0.0/0 md5 line because some tutorial said sopostgres superuser is used for the running appsslmode=require without verify-fulllog_statement = 'all' left on with no rotation — disk fills, then either Postgres or alerts dieBYPASSRLS granted to the app role "for convenience"Each is a finding; prioritize by exposure.
-- 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 | tailtrust auth or host all all 0.0.0.0/0 outside an isolated provisioning step~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.