Database Security Patterns — Recipes
Contents
- Role setup (owner / app / read-only)
- Default-deny grants for new tables
- Parameterized queries by language
- Safe dynamic identifiers
- Row-level security for multi-tenancy
- TLS connection strings
- Audit logging
- Gotchas
Role setup (owner / app / read-only)
-- PostgreSQL. Run as an admin role once per database.
CREATE ROLE app_owner NOLOGIN; -- owns schema, runs migrations
CREATE ROLE app_rw LOGIN PASSWORD :'pw_rw'; -- the application
CREATE ROLE app_ro LOGIN PASSWORD :'pw_ro'; -- analytics / humans
CREATE SCHEMA app AUTHORIZATION app_owner;
GRANT USAGE ON SCHEMA app TO app_rw, app_ro;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_rw;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;Migrations connect as a LOGIN role that is SET ROLE app_owner (or a login
owner role); the app connects as app_rw only.
Default-deny grants for new tables
Grants above cover EXISTING tables only. Make future tables inherit:
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT SELECT ON TABLES TO app_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT USAGE, SELECT ON SEQUENCES TO app_rw;
-- and revoke the PUBLIC default on the database itself:
REVOKE ALL ON DATABASE appdb FROM PUBLIC;Parameterized queries by language
# psycopg (Python)
cur.execute("SELECT * FROM users WHERE email = %s AND status = %s",
(email, status))// node-postgres
await pool.query("SELECT * FROM users WHERE email = $1", [email]);# SQLAlchemy raw text — parameters, never f-strings
conn.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})Injection audit greps (each hit needs review):
grep -rnE 'f"(SELECT|INSERT|UPDATE|DELETE)' --include='*.py' .
grep -rnE '"\s*\+\s*\w+\s*\+?\s*"?.*(WHERE|FROM|VALUES)' --include='*.js' .
grep -rn '\.format(.*SELECT' --include='*.py' .Safe dynamic identifiers
Parameters cannot bind table/column names. Allow-list, never interpolate input:
SORTABLE = {"created_at", "total", "status"} # fixed set, not user-defined
if sort_col not in SORTABLE:
raise ValueError(f"unsortable column: {sort_col}")
cur.execute(f"SELECT * FROM orders ORDER BY {sort_col} LIMIT %s", (limit,))psycopg also offers sql.Identifier() for quoting — still allow-list first.
Row-level security for multi-tenancy
ALTER TABLE app.orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.orders FORCE ROW LEVEL SECURITY; -- applies to owner too
CREATE POLICY tenant_isolation ON app.orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);Per-request, after taking a connection from the pool:
SET app.tenant_id = '4fa2...'; -- SET LOCAL inside a transaction is saferUse SET LOCAL + transaction per request so a pooled connection can never
leak the previous request's tenant.
TLS connection strings
# verify-full = encrypt AND verify hostname against the server cert
postgres://app_rw:pw@db.internal:5432/appdb?sslmode=verify-full&sslrootcert=/etc/ssl/rds-ca.pem
# mysql client equivalent
mysql --ssl-mode=VERIFY_IDENTITY --ssl-ca=/etc/ssl/ca.pem ...sslmode=require encrypts but does NOT verify the server — MITM-able; use
verify-full for anything crossing a network you don't own.
Audit logging
# postgresql.conf — minimum viable audit
log_statement = 'ddl' # every schema/role change
log_connections = on
log_disconnections = on-- pgaudit for compliance-grade auditing
CREATE EXTENSION pgaudit;
ALTER SYSTEM SET pgaudit.log = 'ddl, role, write';
SELECT pg_reload_conf();Ship logs off-host (the attacker who owns the DB host owns its logs).
Gotchas
- Superusers and table owners bypass RLS unless
FORCE ROW LEVEL SECURITYis set — and superusers bypass it regardless. Test policies asapp_rw. GRANT ... ON ALL TABLESis a snapshot, not a subscription — withoutALTER DEFAULT PRIVILEGES, every migration-created table is silently inaccessible (or worse, PUBLIC-readable).sslmode=prefer(the default) silently falls back to plaintext.- Connection pools +
SET app.tenant_idleak across requests unless you useSET LOCALin a transaction or reset on checkout. .pgpass, shell history, and process lists (psql -cwith inline passwords,psshowing DSNs) are the classic secret leaks alongside VCS.pg_hba.conftrustentries mean password-less login for anyone who can reach the socket — audit for them explicitly.- Column encryption with
pgcryptokills indexes on that column (equality possible via deterministic digest column; range queries are gone).