Databases

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.

Updated Houssam Hammoudi, CTOTested with PostgreSQL 18.6

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

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

sql
-- 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:

sql
-- 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:

bash
psql -U postgres -d app -c '\du app_*' -c '\du report_user'
text
         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 connections

Tables 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):

bash
psql -U postgres -d app -c '\dp app.*'
text
                                         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:

bash
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;"
text
 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:

bash
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"
text
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:

text
 [email protected]

ERROR:  permission denied for table customers

The 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:

bash
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;"
text
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 NOLOGIN owns the schema and all tables.
  • Migrations run as a separate login that uses SET ROLE to the owner.
  • ALTER DEFAULT PRIVILEGES FOR ROLE <owner> grants the app its rights on new tables.
  • CONNECT is revoked from PUBLIC on every database, including postgres and template1.
  • CREATE on 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, CREATE and COPY TO PROGRAM fail.

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 free

The Secure Way

More on databases

Postgres with TLS that verifies, backups you can restore, least-privilege roles and row-level security.

All databases guides