Grant least privilege to PostgreSQL roles
-- The owner holds the schema; nothing logs in as it.CREATE ROLE app_owner NOLOGIN;CREATE SCHEMA app AUTHORIZATION app_owner;
-- The runtime role logs in and can only use data, not reshape it.CREATE ROLE app_rw LOGIN PASSWORD 'generated-secret';GRANT USAGE ON SCHEMA app TO app_rw;GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_rw;
-- And for tables created later: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 USAGE ON SEQUENCES TO app_rw;Why it matters
Section titled “Why it matters”Most applications connect as a role that owns their tables, and a surprising
number connect as postgres. Both give a SQL injection — or a bug in a migration
script, or a mistyped psql command — powers it does not need.
Two separations do most of the work:
Superuser is not an application role. A superuser bypasses all permission
checks, including row-level security. It can also COPY ... FROM PROGRAM, which
runs shell commands as the postgres OS user, and read arbitrary files with
pg_read_file(). An injection against a superuser connection is not a data
breach; it is a host compromise.
The owner is not the runtime role. This is the subtler one. A role that owns
a table can always DROP or ALTER it, regardless of what you granted — ownership
implies those rights and they cannot be revoked from an owner. So an app
connecting as the owner can drop its own tables no matter how carefully you
grant. Splitting owner from runtime means DROP TABLE users fails with a
permission error rather than succeeding.
Default privileges are the part people forget
Section titled “Default privileges are the part people forget”GRANT SELECT ON ALL TABLES IN SCHEMA app grants on the tables that exist at
that moment. The next migration creates a table, and the app cannot read it —
which is why so many teams end up re-running grants after every deploy, or give
up and hand the app ownership.
ALTER DEFAULT PRIVILEGES fixes it properly: it says what to grant on objects
created in future, by a specific role. Note the FOR ROLE app_owner clause —
default privileges attach to the creating role, so if your migrations run as
someone else, name that role instead or the rule never fires.
Read-only means more than SELECT
Section titled “Read-only means more than SELECT”A reporting role needs SELECT, but it also needs USAGE on the schema, and it
should not be able to write anywhere:
CREATE ROLE app_ro LOGIN PASSWORD 'generated-secret';GRANT USAGE ON SCHEMA app TO app_ro;GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app GRANT SELECT ON TABLES TO app_ro;ALTER ROLE app_ro SET default_transaction_read_only = on;That last line is a genuine safety net rather than a permission: it makes the session refuse writes even if a grant is wrong somewhere.
Related
Section titled “Related”- Lock down the public schema — the schema this replaces.
- Postgres exposed to the internet — why superuser matters so much.