Module P-6·16 min read

Roles, privileges, Row-Level Security policies, and multi-tenant data isolation — the production access model.

Written by Jatin Jain Saraf · Architect of SupraScan

P-6 — Authentication, Row-Level Security, and Access Control

Most tutorials treat PostgreSQL authentication as a one-liner: create a superuser, give it a password, connect. That works in development. In production it is a security incident waiting to happen.

This module covers the PostgreSQL access model from first principles — roles, privileges, and the Row-Level Security system that lets the database enforce data boundaries between tenants without any application-layer code. By the end you will have the mental model to design a proper production access architecture, and a working multi-tenant RLS setup you can adapt for real projects.


Why the Default Setup Is Dangerous

When you install PostgreSQL locally, the postgres superuser is created automatically. When you deploy to Heroku, Railway, or Supabase, you get a user with broad privileges. Most tutorials say "connect as this user from your app" and move on.

Here is what that means in practice:

  • Your application can DROP TABLE its own data
  • A SQL injection vulnerability has DELETE access to every table
  • One compromised connection can exfiltrate the entire database
  • There is no way to give a read-only analytics tool limited access

The fix is a proper role hierarchy with the principle of least privilege. PostgreSQL has a first-class system for this.


Roles Are Everything

PostgreSQL does not have "users" and "groups" as separate concepts. It has roles. A role can:

  • Log in (it becomes a "user")
  • Own objects (tables, sequences, schemas)
  • Have other roles granted to it (role inheritance)
  • Grant its privileges to other roles

Everything is a role. CREATE USER is just syntax sugar for CREATE ROLE ... WITH LOGIN.

Creating Roles

-- A role that cannot log in — used as a permission container
CREATE ROLE readonly_role;
 
-- A role that can log in (a "user")
CREATE ROLE app_user WITH LOGIN PASSWORD 'strong_password';
 
-- Grant readonly_role's privileges to app_user
GRANT readonly_role TO app_user;

When app_user logs in, it has all privileges that readonly_role has. This is role inheritance.

Role Attributes

Key attributes when creating a role:

AttributeMeaning
LOGINCan authenticate and open a session
SUPERUSERBypasses all access checks — avoid in production
CREATEDBCan create new databases
CREATEROLECan create other roles
REPLICATIONUsed for streaming replication connections
BYPASSRLSBypasses Row-Level Security policies
PASSWORD 'x'Sets the login password
-- Read-only service account for a BI tool
CREATE ROLE bi_reader WITH LOGIN PASSWORD 'bi_password_here';
 
-- Application role — no superuser, no DDL powers
CREATE ROLE app_service WITH LOGIN PASSWORD 'app_password_here';

Granting and Revoking Privileges

PostgreSQL privileges are granted on specific objects: tables, sequences, schemas, databases, functions.

The GRANT Syntax

-- Grant SELECT on a specific table
GRANT SELECT ON TABLE orders TO readonly_role;
 
-- Grant full DML (no DDL) on multiple tables
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE orders, customers, products TO app_service;
 
-- Grant usage on a schema (required to see objects inside it)
GRANT USAGE ON SCHEMA public TO app_service;
 
-- Grant all privileges on all current tables in a schema
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_role;
 
-- Grant privileges on future tables automatically
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO readonly_role;

ALTER DEFAULT PRIVILEGES is the one most people miss. Without it, every new table you create needs a separate GRANT. With it, new tables automatically get the right privileges.

Revoking Privileges

REVOKE DELETE ON TABLE orders FROM app_service;
REVOKE ALL ON TABLE internal_audit_log FROM app_service;

Checking What a Role Can Do

-- All privileges in the database
\dp orders          -- in psql: show privileges for the orders table
 
-- Via system catalog
SELECT grantee, privilege_type, table_name
FROM information_schema.role_table_grants
WHERE table_name = 'orders';

A Production Role Architecture

Here is a pattern that works well for production applications:

-- 1. Owner role: owns the schema and all objects
--    Never used by the application at runtime
CREATE ROLE myapp_owner WITH LOGIN PASSWORD 'migration_password';
 
-- 2. Application role: DML only, no DDL
CREATE ROLE myapp_app WITH LOGIN PASSWORD 'app_password';
 
-- 3. Read-only role: for BI, analytics, debugging
CREATE ROLE myapp_reader WITH LOGIN PASSWORD 'reader_password';
 
-- Grant schema access
GRANT USAGE ON SCHEMA public TO myapp_app, myapp_reader;
 
-- Application gets DML
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO myapp_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO myapp_app;
 
-- Reader gets SELECT only
GRANT SELECT ON ALL TABLES IN SCHEMA public TO myapp_reader;
 
-- Future tables: set defaults for both roles
ALTER DEFAULT PRIVILEGES FOR ROLE myapp_owner IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO myapp_app;
 
ALTER DEFAULT PRIVILEGES FOR ROLE myapp_owner IN SCHEMA public
  GRANT SELECT ON TABLES TO myapp_reader;

Your migration tool connects as myapp_owner. Your application connects as myapp_app. Your BI tool connects as myapp_reader. A SQL injection in the app cannot drop tables or read tables the app role has no access to.


Row-Level Security (RLS)

Role-based access control gets you table-level isolation. Row-Level Security gets you row-level isolation. This is the mechanism behind Supabase's per-user data access, multi-tenant SaaS isolation, and any system where different users should see different subsets of the same table.

How RLS Works

When RLS is enabled on a table, every query against that table automatically gets an invisible WHERE clause appended — the clause defined by the policy. No application code required.

-- Enable RLS on a table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
 
-- Without any policies, the table owner can still access rows.
-- All other users see zero rows (default-deny).

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.