Users, Roles, and Privileges
Use this lesson to create PostgreSQL roles, grant access safely (database, schema, tables, sequences), configure default privileges, and apply row-level security (RLS) when you need database-enforced tenant isolation.
Concept Overview
PostgreSQL uses roles for both "users" and "groups".
A role can:
- own objects
- log in (
LOGIN) - be granted privileges
- contain other roles (membership)
In practice, you usually create:
- an owner role that owns schemas/tables (used by migrations)
- one or more runtime roles for applications (reader/writer)
- optional human roles for admins, developers, and support
Why is it important?
- Security: overprivileged roles turn a small bug into a full data breach.
- Safety: least privilege prevents accidental
DROP,TRUNCATE, or schema changes. - Auditability: separate roles per service/person makes “who did what” traceable.
- Operational stability: correct grants (schemas, sequences, defaults) prevent production outages.
Where does it fit?
This is foundational for:
- production deployments (secure auth + least privilege)
- multi-tenant applications (RLS policies)
- safe migrations (owner role) vs app runtime roles
- security hardening (
pg_hba.conf, SCRAM, TLS) in the next lesson
Syntax & Rules
Core syntax
Create roles (group roles):
CREATE ROLE app_reader;
CREATE ROLE app_writer;
CREATE ROLE app_owner;
Create a login role (a "user") and attach group roles:
CREATE ROLE app_user LOGIN PASSWORD 'change-me';
GRANT app_writer TO app_user;
GRANT app_reader TO app_user;
Grant database + schema access:
GRANT CONNECT ON DATABASE pg_lab TO app_user;
GRANT USAGE ON SCHEMA app TO app_reader, app_writer;
Grant table privileges (existing objects):
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_reader;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_writer;
Sequences (identity/serial inserts):
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_writer;
Available options / parameters
Role attributes you will see often:
| Attribute | Meaning | When to use |
|---|---|---|
LOGIN | role can authenticate | for users/services |
NOLOGIN | role cannot authenticate | group roles (reader/writer) |
INHERIT / NOINHERIT | automatically use privileges of granted roles | INHERIT is typical; NOINHERIT requires SET ROLE |
CREATEDB | can create databases | admin-only |
CREATEROLE | can create/manage roles | admin-only |
SUPERUSER | bypasses checks (do everything) | avoid for apps; restrict to DBAs |
BYPASSRLS | bypass row-level security | rare; security/admin only |
Privilege scopes you should remember:
| Scope | Example | Notes |
|---|---|---|
| Database | GRANT CONNECT ON DATABASE pg_lab TO app_user; | connects only; does not grant schema/table access |
| Schema | GRANT USAGE ON SCHEMA app TO app_reader; | required to access objects in schema |
| Tables | GRANT SELECT ON app.orders TO app_reader; | table-level privileges |
| Sequences | GRANT USAGE ON SEQUENCE app.orders_order_id_seq TO app_writer; | required for identity/serial inserts |
Key rules and considerations
- In PostgreSQL, roles are the unified concept; "users" are simply roles with
LOGIN. - Granting table privileges is not enough: schema USAGE is often the missing piece.
- Inserts into identity/serial columns often require sequence privileges.
ALTER DEFAULT PRIVILEGESaffects future objects created by a specific owner role.- Consider revoking broad defaults from
PUBLIC(see the hardening lesson) to reduce blast radius.
Step-by-Step Examples
These examples use a dedicated schema app and a separate owner role.
Example 1: Application backend roles (owner + reader + writer)
Create roles and a schema owned by app_owner:
-- Run as a privileged admin role
DROP SCHEMA IF EXISTS app CASCADE;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_owner') THEN
CREATE ROLE app_owner NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_reader') THEN
CREATE ROLE app_reader NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_writer') THEN
CREATE ROLE app_writer NOLOGIN;
END IF;
END$$;
CREATE SCHEMA app AUTHORIZATION app_owner;
Expected output:
DROP SCHEMA
DO
CREATE SCHEMA
Create two tables as the owner:
SET ROLE app_owner;
CREATE TABLE app.orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_email text NOT NULL,
status text NOT NULL DEFAULT 'new',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE app.order_items (
order_id bigint NOT NULL REFERENCES app.orders(order_id),
sku text NOT NULL,
qty integer NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, sku)
);
RESET ROLE;
Expected output:
SET
CREATE TABLE
CREATE TABLE
RESET
Grant access:
-- Schema visibility
GRANT USAGE ON SCHEMA app TO app_reader, app_writer;
-- Existing tables
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_reader;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_writer;
-- Identity/serial sequences for inserts
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_writer;
Expected output:
GRANT
GRANT
GRANT
GRANT
Verify with SET ROLE (simulate what your app runtime role can do):
SET ROLE app_writer;
INSERT INTO app.orders (customer_email)
VALUES ('ava@example.com')
RETURNING order_id, status;
RESET ROLE;
Expected output:
SET
order_id | status
----------+--------
1 | new
(1 row)
RESET
Explanation:
app_ownerowns schema/tables (migrations run as owner).app_readercan read data.app_writercan modify data and needs sequence privileges for identity inserts.
Example 2: Read-only dashboard user (grant on views, not base tables)
Create a view owned by app_owner:
SET ROLE app_owner;
CREATE OR REPLACE VIEW app.v_order_summary AS
SELECT
status,
count(*) AS orders,
min(created_at) AS first_order_at,
max(created_at) AS last_order_at
FROM app.orders
GROUP BY status;
RESET ROLE;
Expected output:
SET
CREATE VIEW
RESET
Grant access only to the view:
GRANT USAGE ON SCHEMA app TO app_reader;
GRANT SELECT ON app.v_order_summary TO app_reader;
REVOKE ALL ON app.orders FROM app_reader;
Expected output:
GRANT
GRANT
REVOKE
Test:
SET ROLE app_reader;
SELECT * FROM app.v_order_summary ORDER BY status;
-- Base table should fail
SELECT * FROM app.orders;
Expected output:
SET
status | orders | first_order_at | last_order_at
--------+--------+----------------+--------------
new | 1 | ... | ...
(1 row)
ERROR: permission denied for table orders
Explanation:
- Views can provide a controlled interface.
- Revoke base table access to prevent raw data access.
Example 3: Default privileges (so future tables inherit grants)
Without default privileges, new tables created by app_owner will not automatically be accessible to runtime roles.
Configure defaults as the owner:
SET ROLE app_owner;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
GRANT SELECT ON TABLES TO app_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_writer;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
GRANT USAGE, SELECT ON SEQUENCES TO app_writer;
CREATE TABLE app.audit_log (
audit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
happened_at timestamptz NOT NULL DEFAULT now(),
event text NOT NULL
);
RESET ROLE;
Expected output:
SET
ALTER DEFAULT PRIVILEGES
ALTER DEFAULT PRIVILEGES
ALTER DEFAULT PRIVILEGES
CREATE TABLE
RESET
Verify the new table is readable by app_reader:
SET ROLE app_reader;
SELECT count(*) FROM app.audit_log;
RESET ROLE;
Expected output:
SET
count
-------
0
(1 row)
RESET
Explanation:
- Default privileges apply to objects created by the role that ran ALTER DEFAULT PRIVILEGES.
- Put these statements in migrations run as the owner role.
Example 4: Row-level security (RLS) for tenant isolation
RLS enforces per-tenant access in the database.
Setup a tenant table:
SET ROLE app_owner;
DROP TABLE IF EXISTS app.invoices;
CREATE TABLE app.invoices (
invoice_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
amount_cents bigint NOT NULL CHECK (amount_cents >= 0)
);
INSERT INTO app.invoices (tenant_id, amount_cents)
VALUES (42, 1000), (42, 2500), (7, 999);
ALTER TABLE app.invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation
ON app.invoices
USING (
tenant_id = current_setting('app.tenant_id', true)::bigint
);
GRANT SELECT ON app.invoices TO app_reader;
RESET ROLE;
Expected output:
SET
DROP TABLE
CREATE TABLE
INSERT 0 3
ALTER TABLE
CREATE POLICY
GRANT
RESET
Query as app_reader with different tenant settings:
SET ROLE app_reader;
SET app.tenant_id = '42';
SELECT invoice_id, tenant_id, amount_cents FROM app.invoices ORDER BY invoice_id;
SET app.tenant_id = '7';
SELECT invoice_id, tenant_id, amount_cents FROM app.invoices ORDER BY invoice_id;
RESET ROLE;
Expected output:
SET
SET
invoice_id | tenant_id | amount_cents
------------+-----------+-------------
1 | 42 | 1000
2 | 42 | 2500
(2 rows)
SET
invoice_id | tenant_id | amount_cents
------------+-----------+-------------
3 | 7 | 999
(1 row)
RESET
Explanation:
- RLS filters rows based on a session setting.
- Your application must set
app.tenant_idper connection.
Practical Use Cases
1) Microservices and least privilege
Give each service its own login role and only the grants it needs (often via group roles).
CREATE ROLE billing_service LOGIN PASSWORD 'change-me';
GRANT app_writer TO billing_service;
2) Production read-only reporting
Grant SELECT on views, not base tables.
GRANT SELECT ON app.v_order_summary TO app_reader;
3) Safer migrations
Run migrations as an owner role; run apps as runtime roles.
-- migrations
SET ROLE app_owner;
-- DDL here
RESET ROLE;
4) Multi-tenant enforcement
Use RLS for hard isolation when you can’t trust application logic alone.
ALTER TABLE app.invoices ENABLE ROW LEVEL SECURITY;
5) Delegated access via memberships
Use role membership to simplify access management.
GRANT app_reader TO analyst_user;
Common Mistakes & Troubleshooting
Mistake 1: Forgetting schema USAGE
Wrong SQL:
GRANT SELECT ON app.orders TO app_reader;
-- but no schema USAGE
Bad outcome:
ERROR: permission denied for schema app
Fix:
GRANT USAGE ON SCHEMA app TO app_reader;
Mistake 2: Missing sequence privileges for identity/serial inserts
Wrong setup:
GRANT INSERT ON app.orders TO app_writer;
-- but no sequence grants
Bad outcome:
ERROR: permission denied for sequence orders_order_id_seq
Fix:
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_writer;
Mistake 3: Default privileges don’t apply (wrong owner)
Bad outcome:
- you run
ALTER DEFAULT PRIVILEGESas an admin - tables are created by
app_owner - runtime roles still can’t access new tables
Fix:
- run
ALTER DEFAULT PRIVILEGESas the same role that creates objects (usuallyapp_owner).
Mistake 4: Confusing SET ROLE with authentication
Wrong assumption:
SET ROLE app_readerchanges identity like logging in as that role
Reality:
SET ROLEchanges the active role for privilege checking within a session.
Fix:
- For testing, use
SET ROLEto simulate privileges. - For true authentication testing, connect as that login role.
Debugging tips (numbered)
- Check role membership:
SELECT r.rolname AS role, m.rolname AS member
FROM pg_auth_members am
JOIN pg_roles r ON r.oid = am.roleid
JOIN pg_roles m ON m.oid = am.member
ORDER BY role, member;
- Inspect table privileges:
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = 'app' AND table_name = 'orders'
ORDER BY grantee, privilege_type;
- Verify schema privileges:
SELECT nspname, nspacl
FROM pg_namespace
WHERE nspname = 'app';
- If inserts fail, check sequence privileges in the schema.
Best Practices
✅ Use separate roles: owner (DDL) vs runtime (DML)
❌ Don’t run applications as superuser or as the schema/table owner
✅ Grant schema USAGE + table privileges together
❌ Don’t assume table GRANTs work without schema access
✅ Grant sequence privileges for identity/serial inserts
❌ Don’t forget sequences when you add INSERT
✅ Put grants and default privileges into migrations
❌ Don’t manage production grants manually in ad-hoc sessions
✅ Use views/procedures to constrain access surface
❌ Don’t grant broad table access to third parties
✅ Test RLS with realistic connection behavior
❌ Don’t rely on RLS without ensuring the app sets tenant context
Hands-On Practice
Exercise 1 (Easy): Create reader and writer roles
Task: create app_reader and app_writer roles, and grant reader SELECT and writer SELECT/INSERT on app.orders.
Solution:
CREATE ROLE app_reader NOLOGIN;
CREATE ROLE app_writer NOLOGIN;
GRANT USAGE ON SCHEMA app TO app_reader, app_writer;
GRANT SELECT ON app.orders TO app_reader;
GRANT SELECT, INSERT ON app.orders TO app_writer;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_writer;
Exercise 2 (Medium): Configure default privileges
Task: ensure any new tables created in schema app by app_owner grant SELECT to app_reader automatically.
Solution:
SET ROLE app_owner;
ALTER DEFAULT PRIVILEGES IN SCHEMA app
GRANT SELECT ON TABLES TO app_reader;
RESET ROLE;
Exercise 3 (Advanced): Add RLS tenant isolation
Task: enable RLS on a table app.documents(tenant_id, body) so each tenant only sees its rows based on app.tenant_id.
Solution:
SET ROLE app_owner;
CREATE TABLE app.documents (
doc_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
body text NOT NULL
);
ALTER TABLE app.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_docs
ON app.documents
USING (tenant_id = current_setting('app.tenant_id', true)::bigint);
GRANT SELECT ON app.documents TO app_reader;
RESET ROLE;
Connection to Other Concepts
| Related concept | Why it matters |
|---|---|
| Security Hardening | Roles and grants are necessary, but authentication/network hardening completes the security story. |
| Views | Views help you expose controlled projections and avoid base table access. |
| Stored procedures and functions | You can grant EXECUTE on safe procedures instead of broad table privileges. |
| Triggers | Audit triggers can record current_user/session_user for traceability. |
| Backup and recovery | Backup roles need specific privileges; security is part of recoverability. |
Visual Learning Diagram
flowchart TD
A["Login role\napp_user"] --> B["Member of\napp_writer"]
A --> C["Member of\napp_reader"]
B --> D["Privileges\nINSERT/UPDATE/DELETE"]
C --> E["Privileges\nSELECT"]
D --> F["Schema app\n+ tables + sequences"]
E --> F
F --> G["RLS policies\n(optional)"]
classDef allNodes fill:#3e3e3e,stroke:#ffffff,stroke-width:2px,color:#f5f5f5
classDef highlight fill:#3e3e3e,stroke:#ffffff,stroke-width:4px,color:#f5f5f5
class A,B,C,D,E,F,G allNodes
class G highlight
Common Pitfalls
| Pitfall | Consequence | Prevention |
|---|---|---|
| Grant tables but not schema USAGE | permission denied errors | grant USAGE ON SCHEMA alongside table grants |
| Forget sequence privileges | inserts fail for identity/serial | grant USAGE, SELECT on sequences |
| Default privileges set by wrong role | new tables inaccessible | run defaults as the object owner role |
| App connects as owner/superuser | huge blast radius | dedicated least-privilege runtime roles |
| RLS enabled but context not set | empty results or errors | ensure app sets app.tenant_id per connection |
Quick Reference
CREATE ROLE app_reader NOLOGIN;
CREATE ROLE app_user LOGIN PASSWORD '...';
GRANT app_reader TO app_user;
GRANT USAGE ON SCHEMA app TO app_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO app_reader;
What's Next
- Next: Security Hardening - Add defense-in-depth with
pg_hba.conf, SCRAM/TLS, and safer operational defaults. - Module Overview - Return to this module index and choose another related lesson.