Module P-6·24 min read

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

sql

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
sql

Granting and Revoking Privileges

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

The GRANT Syntax

sql

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

sql

Checking What a Role Can Do

sql

A Production Role Architecture

Here is a pattern that works well for production applications:

sql

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.

sql

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

sql

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.

sql

In your application, before executing queries:

sql

Or in a transaction:

sql

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

sql

Tenant Isolation Policies

sql

Now in your application:

sql

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.

sql

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).

sql

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:

sql

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

sql

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").

sql

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:

sql

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.

Knowledge Check

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 & Register

Discussion

0

Join the discussion

Loading comments...

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