Roles, privileges, Row-Level Security policies, and multi-tenant data isolation — the production access model.
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 TABLEits own data - A SQL injection vulnerability has
DELETEaccess 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
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:
| Attribute | Meaning |
|---|---|
LOGIN | Can authenticate and open a session |
SUPERUSER | Bypasses all access checks — avoid in production |
CREATEDB | Can create new databases |
CREATEROLE | Can create other roles |
REPLICATION | Used for streaming replication connections |
BYPASSRLS | Bypasses Row-Level Security policies |
PASSWORD 'x' | Sets the login password |
Granting and Revoking Privileges
PostgreSQL privileges are granted on specific objects: tables, sequences, schemas, databases, functions.
The GRANT Syntax
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
Checking What a Role Can Do
A Production Role Architecture
Here is a pattern that works well for production applications:
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.
Once RLS is enabled and no policies exist, non-owner roles see an empty table. You then define policies to grant selective access.
Creating Policies
USING is the filter on rows being read or modified — "which rows can this role see/touch?"
WITH CHECK is the filter on new/updated data — "what values can this role write?"
Simple Per-User Policy
The classic example: users can only see their own rows.
In your application, before executing queries:
Or in a transaction:
current_setting('app.current_user_id') reads this session variable. The database filters rows automatically — the application never has to add WHERE user_id = ? to every query.
Multi-Tenant RLS: Isolating Organizations
The most powerful use of RLS in production is multi-tenant data isolation: a single database serves many customers (tenants), each seeing only their own data.
Schema Setup
Tenant Isolation Policies
Now in your application:
The database enforces isolation. Even if a bug in application code constructs a query that could read another tenant's data, the RLS policy blocks it at the database level.
PERMISSIVE vs RESTRICTIVE Policies
By default all policies are PERMISSIVE: a row is accessible if any policy allows it.
RESTRICTIVE policies use AND logic — a row is accessible only if it passes all restrictive policies (plus at least one permissive policy if any permissive policies exist).
This is the right pattern for soft deletes. You want users to see their own rows (permissive policy), but never deleted rows regardless of other policies (restrictive policy).
The BYPASSRLS Attribute
Table owners bypass RLS by default. Superusers bypass it. You can also grant bypass explicitly:
Use this for:
- Migration scripts that need to operate on all rows
- Admin dashboards that serve internal users seeing cross-tenant data
- Backup/restore operations
Be careful: BYPASSRLS is a powerful attribute. Keep it to dedicated admin roles, not your main application role.
Viewing and Managing Policies
FORCE ROW LEVEL SECURITY is useful in testing — it makes the table owner subject to policies too, so you can verify your policies behave correctly without switching roles.
Common Mistakes
Forgetting WITH CHECK
If you define a USING clause without a WITH CHECK, PostgreSQL uses the USING expression for both reads and writes. For SELECT-only policies this is fine. For INSERT/UPDATE policies, always think about what the WITH CHECK should be separately.
Enabling RLS without any policies The default-deny behaviour (no rows visible) surprises people. If you enable RLS and forget to add a policy, the table appears empty to all non-owner roles. This can cause silent failures.
Using session variables without SET LOCAL
SET app.tenant_id = '7' persists for the entire connection. In a connection pool, the next request on the same connection will see the previous tenant's context. Always use SET LOCAL inside a transaction, or set_config('app.tenant_id', '7', true) (the third argument true means "local to this transaction").
Not enabling RLS on every table in the schema A multi-tenant schema where only some tables have RLS is a leaking tenant boundary. Audit every table.
Practical Exercise
Build the multi-tenant RLS setup from this module:
The database enforces the tenant boundary. The application never needs to add WHERE tenant_id = ? to individual queries — and even if it forgets to, the RLS policy ensures no data leaks.
Summary
PostgreSQL's access control system has two layers:
Roles and privileges control which roles can perform which operations on which objects — table-level granularity. The right production pattern is a role hierarchy: owner for DDL, app role for DML, reader role for SELECT, and no superuser connections from application code.
Row-Level Security adds row-level granularity using policies that are automatically applied to every query. The database enforces data isolation rules that would otherwise need careful application-layer implementation in every query. For multi-tenant systems, RLS is the cleanest way to prevent data leaks between tenants.
Next up: P-7 — Full-Text Search — where we replace Elasticsearch with native PostgreSQL text search using tsvector, tsquery, GIN indexes, and relevance ranking.
A multi-tenant SaaS application uses PostgreSQL Row-Level Security (RLS) to isolate tenant data. The application uses a connection pool (like PgBouncer). A developer sets the tenant context using SET app.tenant_id = '42'; right before executing a query. Why is this a critical security vulnerability?
You have enabled RLS on a posts table (ALTER TABLE posts ENABLE ROW LEVEL SECURITY;) but you have not yet created any policies for it. The table is owned by the admin_role. Your application connects using the app_service role, which has been granted SELECT, INSERT, UPDATE, DELETE privileges on the table. What happens when the app_service role runs SELECT * FROM posts;?
When designing a robust role architecture for a production PostgreSQL database, what is the primary purpose of the ALTER DEFAULT PRIVILEGES command?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion