Skip to main content

Postgres Row-Level Security (RLS) Limitations and Alternatives

Tianzhou · Jul 12, 2026

PostgreSQL's built-in Row-Level Security (RLS) allows database administrators to define policies that control which rows can be returned by queries or modified by data manipulation commands on a per-user basis. When enabled, all access to a table must be allowed by a security policy, with table owners typically bypassing these restrictions unless explicitly configured otherwise.

Key characteristics include:

  • Enabled at the table level with ALTER TABLE ... ENABLE ROW LEVEL SECURITY
  • Policies can be specific to commands (SELECT, INSERT, UPDATE, DELETE) or roles
  • Uses Boolean expressions to determine which rows are visible/modifiable
  • Multiple policies can be combined using OR (permissive) or AND (restrictive)
  • Superusers and roles with BYPASSRLS attribute bypass row security

While powerful, RLS has performance impact, limited flexibility, and operational overhead that hinder broader adoption. Let's review them in details and discuss the alternatives.

Performance Impact

A common misconception is that RLS filters rows after they are retrieved. It doesn't: PostgreSQL injects policy expressions into the query plan as security quals, so a simple policy can use an index just like a hand-written WHERE clause. The real costs come from three subtler mechanisms.

Leakproof Evaluation Order

Security quals must be evaluated before any user-supplied predicate that could leak row contents (for example, through a function's error message). Only operators and functions marked LEAKPROOF may be reordered or pushed down ahead of them. Most built-in comparison operators qualify; LIKE pattern matching and virtually all user-defined functions do not:

-- RLS Policy
CREATE POLICY tenant_isolation ON orders
FOR ALL TO app_user
USING (tenant_id = current_setting('app.tenant_id')::int);

-- Fine: = on integers is leakproof, so the planner can combine the
-- policy qual with the user predicate and use an index on
-- (tenant_id, status).
SELECT * FROM orders WHERE status = 'pending';

-- Not fine: LIKE is not leakproof, so it cannot be evaluated before
-- the policy qual. Plans that would push this selective predicate
-- into an index scan are no longer available.
SELECT * FROM orders WHERE notes LIKE '%refund%';

Subquery Policies

A policy that contains a subquery executes as a sub-plan attached to the table scan. The planner cannot flatten it into a join that it could reorder or optimize:

CREATE POLICY department_policy ON orders
USING (department_id IN (
  SELECT dept_id FROM user_departments
  WHERE user_id = current_setting('app.user_id')::int
));

Policy Composition Complexity

Multiple RLS policies on the same table are combined into a single expression (permissive policies with OR, restrictive policies with AND) that every scanned row must pass, on top of the query's own predicates. Each additional policy compounds the evaluation cost and further constrains the plans available to the optimizer.

Alternatives: Views

Pre-Filtered Result Sets A plain view is inlined into the query at planning time, and the optimizer treats its predicates like any other, pushing them down and using indexes freely:

-- Security view
CREATE VIEW tenant_orders AS
SELECT * FROM orders
WHERE tenant_id = current_setting('app.tenant_id')::int;

-- Query against view
SELECT * FROM tenant_orders WHERE status = 'pending';

-- PostgreSQL can optimize this as:
-- SELECT * FROM orders
-- WHERE tenant_id = current_setting('app.tenant_id')::int
--   AND status = 'pending'
-- And use compound indexes on (tenant_id, status)
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status);

The catch: that optimization freedom is exactly why a plain view is not a hard security boundary. Because its predicates are ordinary quals, a user running their own (non-leakproof) function against the view could observe rows before the filter applies. CREATE VIEW ... WITH (security_barrier) closes the hole, but reintroduces the same leakproof evaluation-order restrictions as RLS.

So the honest trade-off is not "views are faster than RLS." Plain views are faster because they promise less, and that is acceptable when the SQL reaching the database comes from trusted applications rather than adversarial users, which is the common case.

Of course, View has its own limitations:

  • Proliferation of Views: May need many views for different access patterns
  • Maintenance Overhead: Changes to base tables may require view updates
  • Limited Dynamism: Less flexible for highly dynamic permission models
  • Write Operations: More complex to handle INSERT/UPDATE operations

Flexibility Limitation

SQL-Only Policy Expressions

RLS policies are constrained to SQL expressions, which limits what you can express:

-- RLS can only do this:
CREATE POLICY simple_tenant_policy ON documents
USING (tenant_id = current_setting('app.tenant_id')::int);

-- But cannot do complex business logic like:
-- "Users can access documents if they're in the same department
--  AND it's during business hours
--  AND they've completed required training
--  AND the document hasn't been flagged by content moderation
--  AND external compliance API approves access"

Static Context Limitations

RLS policies are evaluated with limited context - primarily database session variables and current user information:

-- Limited to session context
CREATE POLICY time_based_policy ON sensitive_data
USING (
  EXTRACT(hour FROM NOW()) BETWEEN 9 AND 17 -- Crude business hours
  AND EXTRACT(dow FROM NOW()) BETWEEN 1 AND 5 -- Weekdays only
);

-- Cannot consider:
-- - User's actual timezone and location
-- - Company-specific holiday calendar
-- - User's current device/IP reputation
-- - Recent security events or risk scores
-- - Dynamic compliance requirements

No External System Integration

RLS cannot make external API calls or integrate with other systems:

-- This is impossible in RLS:
-- CREATE POLICY compliance_policy ON financial_records
-- USING (external_compliance_service_check(user_id, document_id, current_timestamp));

-- You're limited to data that exists in your PostgreSQL instance

Alternatives: Application-Level Logic

While RLS forces you to work within the constraints of SQL and database session context, modern authorization systems can incorporate any data source, apply any business logic, and make decisions based on the full context of each request.

Below shows an ABAC (Attribute-Based Access Control) system that incorporates different attribute types into authorization decisions:

// Using Casbin or similar ABAC engine
const authorizationEngine = {
  async checkAccess(subject, resource, action, context) {
    const policy = await this.evaluatePolicy({
      // User attributes
      user: {
        id: subject.id,
        roles: subject.roles,
        department: subject.department,
        clearanceLevel: subject.clearanceLevel,
        lastTrainingDate: subject.lastTrainingDate,
        currentLocation: await geoService.getLocation(context.ip),
        riskScore: await riskEngine.getUserRiskScore(subject.id),
      },

      // Resource attributes
      resource: {
        id: resource.id,
        classification: resource.dataClassification,
        owner: resource.owner,
        createdDate: resource.createdDate,
        lastModified: resource.lastModified,
        complianceFlags: await complianceService.getFlags(resource.id),
      },

      // Environmental attributes
      environment: {
        currentTime: new Date(),
        userAgent: context.userAgent,
        networkLocation: context.networkLocation,
        isBusinessHours: await businessCalendar.isBusinessHours(),
        threatLevel: await securityService.getCurrentThreatLevel(),
      },

      action: action,
    });

    return policy.decision;
  },
};

// Usage
const canAccess = await authorizationEngine.checkAccess(
  currentUser,
  requestedDocument,
  'read',
  requestContext,
);

Operational Overhead

Management Overhead

DBAs become organizational bottlenecks when managing RLS policies due to the manual intervention required at every stage of the policy lifecycle. Policy creation demands deep PostgreSQL expertise to craft secure SQL logic while analyzing performance implications and cross-policy dependencies. Managing existing policies becomes increasingly complex as organizations accumulate hundreds of interconnected policies without proper versioning, dependency tracking, or impact analysis capabilities. Decommissioning policies requires manual archaeological work to identify relationships and ensure safe removal without breaking dependent systems.

Developer Self-Service Usability Limitations

Developers face friction when working with RLS as there are no self-service capabilities for access management or testing. RLS provides no mechanism for just-in-time access requests, emergency debugging access, or temporary permissions, forcing developers to rely on manual DBA intervention even during critical incidents. The development experience is impacted by inability to create local test environments with proper authorization logic, poor debugging capabilities for authorization failures, and no way to unit test against different access scenarios.

Alternatives: Middleware + Tooling

Instead of using database-level RLS, you can implement an authorization middleware layer that intercepts queries and enforces security policies at the application tier.

Open Policy Agent (OPA): Policies are defined in the Rego language and managed in version control systems like Git, enabling best practices such as code reviews, automated testing, and CI/CD integration.

Bytebase: Use Bytebase as a database access management platform to provide developers with self-service access portals. It automates approval workflows and policy generation, removing the need for DBAs to manually create and revoke temporary permissions. Bytebase also offers audit trails, break-glass access procedures, and integrates with existing identity systems.

Summary

RLS works well for straightforward data filtering—such as tenant_id isolation—where database-level enforcement offers fast and reliable security baselines. It excels at applying simple predicates that map directly to database queries, without requiring external integrations or complex business logic.

However, for a production-grade multi-tenant service, a layered approach is often more scalable:

  1. RLS for Basic Tenant Isolation Use RLS to enforce simple, row-level access control based on fields like tenant_id. This provides efficient, in-database filtering with minimal operational overhead.

  2. Application Logic + OPA for Complex Policies For advanced authorization needs, integrate policy engines like Open Policy Agent (OPA) at the application layer. OPA handles dynamic logic that SQL cannot express—such as time-based access, workflow states, external API checks, or user roles—while supporting Git-based version control, automated testing, and policy simulation.

  3. Bytebase for Just-in-Time Human Access For operational scenarios like production troubleshooting or emergency access, Bytebase offers a self-service portal with automated approval workflows, audit trails, and break-glass procedures. This eliminates manual DBA intervention while preserving governance and traceability.

Back to blog

Explore the standard for database governance