Least-privilege Postgres roles for an app, the secure way
The app connects as the postgres superuser because "it was easier during the demo". The demo was three years ago. Today one SQL injection away from your login form sits DROP DATABASE, COPY TO PROGRAM, and a very long weekend.
The short answer
Create a NOLOGIN owner role that owns the schema and tables, NOLOGIN group roles with row privileges only, and one login role per client in exactly one group. Revoke the PUBLIC defaults, run migrations as the owner, and use ALTER DEFAULT PRIVILEGES so new tables get the same grants. Test what each login cannot do.
On this page
What goes wrong
Most applications use one database login for everything: reading rows, running migrations, and sometimes admin tasks. Often that login owns the tables, or is a superuser.
The owner of a table can drop it, alter it and grant it to others. A
superuser can do anything, including COPY ... TO PROGRAM, which runs a shell
command on the database server. When an attacker finds SQL injection in the
app, they get exactly the rights of the app's login.
Two defaults make it worse. Every role can connect to every database, and new tables created later do not inherit the grants you gave on old tables. Teams "fix" the second by granting more, until the app login has everything again.
What the docs say
The right to modify or destroy an object is inherent in being the object's owner, and cannot be granted or revoked in itself.
Source: PostgreSQL 18 docs, Privileges
For other types of objects, the default privileges granted to PUBLIC are as follows: CONNECT and TEMPORARY (create temporary tables) privileges for databases; EXECUTE privilege for functions and procedures; and USAGE privilege for languages and data types (including domains).
Source: PostgreSQL 18 docs, Privileges
Change default privileges for objects created by the target_role, or the current role if unspecified.
Source: PostgreSQL 18 docs, ALTER DEFAULT PRIVILEGES
The last quote hides the most common bug. Default privileges apply to objects
created by one specific role. If a migration runs as a different role than
the one named in FOR ROLE, the new table gets no grants, and someone widens
permissions to fix the outage.
The secure configuration
-- roles.sql: least-privilege roles for one application database. Run as a superuser.
-- Passwords here are test values; in production set them from a secret store.
-- 1. Nobody gets anything by default.
CREATE DATABASE app;
REVOKE ALL ON DATABASE app FROM PUBLIC;
-- Every role can connect to "postgres" and "template1" by default. Close that too.
REVOKE CONNECT ON DATABASE postgres FROM PUBLIC;
REVOKE CONNECT ON DATABASE template1 FROM PUBLIC;
-- 2. The owner: owns the schema and every table. Cannot log in.
CREATE ROLE app_owner NOLOGIN;
-- 3. Group roles that carry privileges. Cannot log in.
CREATE ROLE app_rw NOLOGIN;
CREATE ROLE app_ro NOLOGIN;
-- 4. Login roles: one per client, member of exactly one group.
CREATE ROLE app_migrator LOGIN PASSWORD 'test-migrator-pw' IN ROLE app_owner;
CREATE ROLE app_user LOGIN PASSWORD 'test-user-pw' IN ROLE app_rw CONNECTION LIMIT 50;
CREATE ROLE report_user LOGIN PASSWORD 'test-report-pw' IN ROLE app_ro CONNECTION LIMIT 5;
-- The app user cannot run away with the server.
ALTER ROLE app_user SET statement_timeout = '5s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '30s';
GRANT CONNECT ON DATABASE app TO app_owner, app_rw, app_ro;
\connect app
-- 5. One schema, owned by app_owner. Nobody else may create objects in it.
REVOKE ALL ON SCHEMA public FROM PUBLIC;
CREATE SCHEMA app AUTHORIZATION app_owner;
GRANT USAGE ON SCHEMA app TO app_rw, app_ro;
ALTER ROLE app_user IN DATABASE app SET search_path = app;
ALTER ROLE report_user IN DATABASE app SET search_path = app;
-- 6. Privileges on tables created LATER by the owner (migrations).
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, SELECT ON SEQUENCES TO app_rw;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT SELECT ON TABLES TO app_ro;Migrations log in as app_migrator and switch to the owner first, so every
new object is owned by app_owner and picks up the default privileges:
-- A migration, run by app_migrator as the owner role.
SET ROLE app_owner;
CREATE TABLE app.customers (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL, note text);
CREATE TABLE app.invoices (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_id bigint REFERENCES app.customers, amount numeric NOT NULL);
INSERT INTO app.customers (email) VALUES ('[email protected]'), ('[email protected]');Pair this with pg_hba.conf rules that allow each login only from where it
runs, with scram-sha-256, and keep app_migrator out of the running app.
Prove it
The roles. Only the three login roles can connect, and each has a connection limit that fits its job:
psql -U postgres -d app -c '\du app_*' -c '\du report_user' List of roles
Role name | Attributes
--------------+----------------
app_migrator |
app_owner | Cannot login
app_ro | Cannot login
app_rw | Cannot login
app_user | 50 connections
List of roles
Role name | Attributes
-------------+---------------
report_user | 5 connectionsTables created by the migration got the grants automatically. app_rw has
arwd (insert, select, update, delete) and no D, x, t or m
(truncate, references, trigger, maintain):
psql -U postgres -d app -c '\dp app.*' Access privileges
Schema | Name | Type | Access privileges | Column privileges | Policies
--------+------------------+----------+------------------------------+-------------------+----------
app | customers | table | app_owner=arwdDxtm/app_owner+| |
| | | app_rw=arwd/app_owner +| |
| | | app_ro=r/app_owner | |
app | customers_id_seq | sequence | app_owner=rwU/app_owner +| |
| | | app_rw=rU/app_owner | |
app | invoices | table | app_owner=arwdDxtm/app_owner+| |
| | | app_rw=arwd/app_owner +| |
| | | app_ro=r/app_owner | |
app | invoices_id_seq | sequence | app_owner=rwU/app_owner +| |
| | | app_rw=rU/app_owner | |
(4 rows)app_user does its normal work:
psql -h 127.0.0.1 -U app_user -d app -c "show search_path;" -c "insert into customers (email) values ('[email protected]');" -c "select count(*) from customers;" search_path
-------------
app
(1 row)
count
-------
3
(1 row)And fails at everything else: dropping or altering tables, creating tables in
either schema, running a program through COPY, a long query, and
connecting to another database:
psql -h 127.0.0.1 -U app_user -d app -c "drop table customers;"
psql -h 127.0.0.1 -U app_user -d app -c "create table evil (x int);"
psql -h 127.0.0.1 -U app_user -d app -c "alter table customers add column is_admin bool;"
psql -h 127.0.0.1 -U app_user -d app -c "create table public.evil (x int);"
psql -h 127.0.0.1 -U app_user -d app -c "copy customers to program 'id';"
psql -h 127.0.0.1 -U app_user -d app -c "select pg_sleep(10);"
psql -h 127.0.0.1 -U app_user -d postgres -c "select 1"ERROR: must be owner of table customers
ERROR: permission denied for schema app
LINE 1: create table evil (x int);
^
ERROR: must be owner of table customers
ERROR: permission denied for schema public
LINE 1: create table public.evil (x int);
^
ERROR: permission denied to COPY to or from an external program
DETAIL: Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program.
HINT: Anyone can COPY to stdout or from stdin. psql's \copy command also works for anyone.
ERROR: canceling statement due to statement timeout
psql: error: connection to server at "127.0.0.1", port 5432 failed: FATAL: permission denied for database "postgres"
DETAIL: User does not have CONNECT privilege.report_user reads and nothing more:
[email protected]
ERROR: permission denied for table customersThe trap: app_migrator creates a table without SET ROLE app_owner. The
table belongs to app_migrator, the default privileges do not apply, and the
app cannot read it:
psql -h 127.0.0.1 -U app_migrator -d app -c "create table app.audit (x int);"
psql -h 127.0.0.1 -U app_user -d app -c "select * from audit;"
psql -U postgres -d app -c "select tablename, tableowner from pg_tables where schemaname='app' order by 1;"ERROR: permission denied for table audit
tablename | tableowner
-----------+--------------
audit | app_migrator
customers | app_owner
invoices | app_owner
(3 rows)The test script is secure-tests/least-privilege-postgres-roles-app/run.sh.
Mistakes people make
The app logs in as the owner
Then SQL injection can drop and alter tables, because ownership rights cannot
be revoked. The owner role should be NOLOGIN, used only through SET ROLE
during migrations.
GRANT ALL ON ALL TABLES
ALL includes TRUNCATE, REFERENCES, TRIGGER and MAINTAIN. The app
needs SELECT, INSERT, UPDATE, DELETE, often on fewer tables than that.
Default privileges for the wrong role
ALTER DEFAULT PRIVILEGES without FOR ROLE applies to the role running the
command, usually the admin. Name the role that creates tables in migrations.
Leaving PUBLIC alone
Every role can connect to postgres and template1 by default. Revoke
CONNECT from PUBLIC on every database, then grant it to the roles that
need it.
One login for many services
When two services share a login, you cannot tell their queries apart in logs, and you cannot take away one service's access without breaking the other. One login per client.
Checklist
- The app login is not a superuser and does not own any table.
- An owner role with
NOLOGINowns the schema and all tables. - Migrations run as a separate login that uses
SET ROLEto the owner. ALTER DEFAULT PRIVILEGES FOR ROLE <owner>grants the app its rights on new tables.CONNECTis revoked fromPUBLICon every database, includingpostgresandtemplate1.CREATEon schemas is limited to the owner.- Each login has a connection limit and a
statement_timeout. - A test logs in as the app and checks that
DROP,ALTER,CREATEandCOPY TO PROGRAMfail.
The best time to find out what your app login can do is in a test, not in an incident report. Give it the rights to do its job, and a test that proves it has nothing more.
H2-CSSE
Learn it on a live range
Secure APIs and authorization in code, in Secure Software Engineering: a real host in your browser, and every objective checked on the machine.
Start freeThe Secure Way
More on databases
Postgres with TLS that verifies, backups you can restore, least-privilege roles and row-level security.
All databases guides