What Row-Level Security Is — and What Problem It Solves

Row-level security is a feature built into PostgreSQL (since version 9.5) that allows database administrators to define policies controlling which rows a given database role or session may access. When RLS is enabled on a table, the database engine automatically appends policy predicates to every SELECT, INSERT, UPDATE, and DELETE statement — before the query planner executes the operation. The application never sees rows that fail the policy check. They are invisible at the engine level, not at the application level.

In a multi-tenant telehealth context, this means a query issued in the context of Clinic A cannot return rows belonging to Clinic B — not because the application chose not to fetch them, but because the database engine filtered them out before the result set was assembled. The enforcement is not opt-in per query. It is structural, permanent, and cannot be bypassed by application code running under a constrained role.

The problem RLS solves is the gap between the isolation guarantee you intend to provide and the isolation guarantee that actually exists in production. Most multi-tenant SaaS platforms intend to isolate tenants through application-layer WHERE tenant_id = :tenant_id clauses. RLS makes that isolation a database-level invariant rather than a developer convention that must be upheld across every query, every endpoint, every developer, forever. The broader HIPAA framework that RLS supports is covered in our definitive HIPAA guide for specialty medicine telehealth.

Healthcare Data Breach Reality
73%

Of healthcare SaaS data breaches involving cross-tenant exposure are traceable to application-layer filtering failures — missing WHERE clauses, ORM eager-loading bugs, or SQL injection — not to database engine vulnerabilities. RLS eliminates this entire category of exposure.

Multi-Tenant Architecture Options

There are three common approaches to multi-tenancy in healthcare SaaS platforms, and each carries meaningfully different trade-offs for HIPAA compliance, operational overhead, and scalability.

Separate Databases per Tenant

The strongest isolation model: every tenant gets a dedicated database instance (or database within a cluster). There is no shared storage at the schema level. A misconfiguration affecting one tenant cannot expose another tenant's data.

In practice, this model becomes operationally prohibitive at scale. Running 100 dedicated PostgreSQL instances requires 100x the infrastructure cost, migration management, and monitoring overhead. Schema changes must be applied across every instance. Connection pooling becomes complex. Backup and disaster recovery multiply linearly with tenant count. For most telehealth SaaS platforms, this model is only appropriate during early single-tenant stages or for enterprise customers with explicit contractual isolation requirements.

Shared Database, Shared Schema

The most common approach: all tenants share a single database and a single schema, with a tenant_id foreign key on every table. Isolation is enforced entirely by the application layer — every query must include a WHERE tenant_id = ? clause.

This model is operationally efficient: one migration applies to all tenants, one connection pool serves all tenants, one monitoring setup covers everything. The liability is that isolation is entirely dependent on developer discipline. Every new query, every new endpoint, every new hire must correctly apply tenant filtering. No structural enforcement exists at the database layer.

Shared Database with Row-Level Security

The optimal model for HIPAA-regulated multi-tenant platforms: a shared database and schema (operational efficiency of the shared-schema approach), combined with PostgreSQL RLS policies that enforce tenant isolation at the engine level. Application code still sets WHERE tenant_id = ? for performance reasons (query planner optimization), but the RLS policy ensures that even if the WHERE clause is missing, the query returns no cross-tenant data.

This gives you the operational simplicity of a shared schema with the structural isolation guarantee that the separate-database model provides — without 100x the infrastructure.

Approach Isolation Level Operational Cost HIPAA Defense-in-Depth Recommended For
Separate Databases Strongest Very High Yes Single-tenant / enterprise contracts
Shared Schema Only App-layer only Low No Not recommended for PHI platforms
Shared Schema + RLS DB-enforced Low Yes Multi-tenant telehealth SaaS

How PostgreSQL RLS Works

CREATE POLICY and Session Variables

Enabling RLS on a table requires two steps: first, enabling the feature on the table; second, creating at least one policy that controls access. Without a policy, a table with RLS enabled is inaccessible to all non-superuser roles — a safe default.

PostgreSQL — Enable RLS and Create Tenant Isolation Policy
-- Step 1: Enable RLS on the patients table
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;

-- Step 2: Create an isolation policy that uses a session variable
-- The session variable app.current_tenant is set by the application
-- at the start of each transaction via SET LOCAL
CREATE POLICY tenant_isolation_policy
  ON patients
  AS RESTRICTIVE
  USING (
    tenant_id = current_setting('app.current_tenant', TRUE)
  );

-- Step 3: Force the policy to apply even to the table owner
ALTER TABLE patients FORCE ROW LEVEL SECURITY;

The session variable app.current_tenant is set by the application at the beginning of every transaction. In Python with asyncpg, this looks like:

Python (asyncpg) — Setting Tenant Context Per Transaction
async def get_patients(tenant_id: str, pool: asyncpg.Pool):
    async with pool.acquire() as conn:
        async with conn.transaction():
            # Set tenant context WITHIN the transaction
            # SET LOCAL resets at transaction end — safe for pooled connections
            await conn.execute(
                f"SET LOCAL app.current_tenant = $1",
                tenant_id
            )
            # RLS policy now filters automatically
            # No WHERE tenant_id clause needed (but add it for query plan optimization)
            return await conn.fetch(
                "SELECT * FROM patients WHERE tenant_id = $1",
                tenant_id
            )

The critical detail is SET LOCAL rather than SET. SET LOCAL scopes the variable to the current transaction and resets automatically when the transaction ends. In a connection pool where connections are reused across requests, using SET (session-scoped) would allow a subsequent request to inherit the previous request's tenant context — a silent cross-tenant exposure that is extremely difficult to detect in production.

PERMISSIVE vs. RESTRICTIVE Policies

PostgreSQL supports two policy types that interact differently when multiple policies exist on the same table.

PERMISSIVE policies (the default) are combined with OR: a row passes if it satisfies any PERMISSIVE policy. This is useful for adding feature-specific access grants — for example, allowing administrators to see rows across tenants. However, stacking PERMISSIVE policies can inadvertently open up access as your policy set grows.

RESTRICTIVE policies are combined with AND: a row passes only if it satisfies all RESTRICTIVE policies. For tenant isolation, RESTRICTIVE is the correct choice. It means your baseline tenant isolation check is a hard requirement that all subsequent PERMISSIVE policies still cannot bypass.

Policy Combination Logic

PostgreSQL evaluates policies as: row is visible = (any PERMISSIVE policy passes) AND (all RESTRICTIVE policies pass). Define your tenant isolation as RESTRICTIVE, then build feature-specific access rules as PERMISSIVE policies layered on top. The tenant boundary can never be crossed regardless of how many PERMISSIVE grants you add.

Role-Based Policies for Healthcare

Healthcare platforms require granular access control within a tenant: a medical assistant should see patient demographics but not lab values; a prescriber should see lab results but not billing records; a billing coordinator should see financial data but not clinical notes. PostgreSQL RLS supports this by attaching policies to specific database roles.

PostgreSQL — Role-Based Policies Within a Tenant
-- Policy: clinical staff can read patient records in their tenant
CREATE POLICY clinical_read_policy
  ON patients
  FOR SELECT
  TO clinical_role
  USING (
    tenant_id = current_setting('app.current_tenant', TRUE)
    AND record_type IN ('demographics', 'clinical')
  );

-- Policy: billing staff can only see demographic + billing records
CREATE POLICY billing_read_policy
  ON patients
  FOR SELECT
  TO billing_role
  USING (
    tenant_id = current_setting('app.current_tenant', TRUE)
    AND record_type IN ('demographics', 'billing')
  );

-- Policy: tenant admins can read all record types within their tenant
CREATE POLICY tenant_admin_read_policy
  ON patients
  FOR ALL
  TO tenant_admin_role
  USING (
    tenant_id = current_setting('app.current_tenant', TRUE)
  );

Why Application-Layer Filtering Alone Is Insufficient

The argument for application-layer filtering is simple: add WHERE tenant_id = :id to every query, use parameterized queries to prevent SQL injection, and you're done. This argument fails in practice for four distinct reasons, any one of which can cause a cross-tenant PHI exposure event.

1. SQL Injection

Parameterized queries prevent injection in the parameters they control. But legacy codebases, third-party libraries, and dynamic query builders frequently construct portions of SQL strings dynamically — table names, column names, ORDER BY clauses, and LIMIT values are common injection vectors that parameterization cannot protect. A single injectable endpoint that reaches a table without RLS allows an attacker to query arbitrary patient records. RLS limits the blast radius of any injection to the current tenant's data, even when the injection succeeds.

2. ORM and Framework Bugs

Object-relational mappers have a documented history of eager-loading bugs that return related objects without applying the parent query's WHERE clause filters. Django, SQLAlchemy, Prisma, and ActiveRecord have all shipped versions that, under specific join or prefetch conditions, returned cross-tenant data when application-layer filtering was the only isolation mechanism. These are not hypothetical: they have caused reportable breaches at production SaaS companies. RLS is immune to ORM bugs because the filtering happens below the ORM, at the database engine level.

3. Developer Error

A codebase with 200 API endpoints and 500 queries has 500 opportunities for a developer to forget a WHERE clause. New endpoints added by new engineers who are unfamiliar with the multi-tenant architecture are the highest-risk scenario. A single endpoint that queries a patient table without a tenant filter will return all patients across all tenants to anyone who hits it. In a HIPAA context, this is an immediately reportable breach. Application-layer-only platforms rely on code review catching every such omission — a process that fails regularly in practice.

4. Raw SQL Access During Incidents

During production incidents, engineers often connect directly to the database to run diagnostic queries. A direct database connection that bypasses the application has no application-layer filtering applied. Without RLS, any query run manually returns cross-tenant data. RLS policies apply to all connections including direct psql sessions running under constrained roles — the defense is always on.

The Compounding Risk Problem

Each of the four failure modes above is individually low-probability but not zero. In a codebase that is actively developed for several years, the probability that zero of these failure modes occurs approaches zero. Application-layer filtering alone asks "will every developer write every query correctly for the entire lifetime of the product?" RLS changes the question to "is our RLS policy correctly defined?" — a far smaller, more auditable, more testable surface.

RLS as HIPAA Defense-in-Depth

HIPAA's Security Rule does not mandate RLS by name. It mandates that covered entities implement "technical security measures to guard against unauthorized access to ePHI that is being transmitted over an electronic communications network" (45 CFR 164.312(e)(1)) and "technical policies and procedures for electronic information systems that maintain ePHI to allow access only to those persons or software programs that have been granted access rights" (45 CFR 164.312(a)(1)).

RLS directly satisfies the second requirement by implementing access control at the data store level, not solely at the application level. When a covered entity's Security Officer documents the technical safeguards in their Risk Analysis, RLS allows them to accurately state: "Tenant isolation is enforced at the database engine level via row-level security policies. No application-layer bug, including SQL injection or developer error, can return PHI belonging to a different tenant."

This statement is materially stronger than "we apply WHERE tenant_id filters in application code." It changes the character of a potential breach: a developer who writes a query without a WHERE clause produces an application bug, not a PHI exposure event — because the database returns no cross-tenant rows regardless.

The HIPAA Security Rule's broader principle is defense-in-depth: multiple overlapping safeguards such that no single failure results in unauthorized access. The RLS layer, the application-layer filtering, the authentication layer, the network perimeter, and the audit logging layer together create this defense-in-depth posture. RLS is not a replacement for any of the other layers — it is the one safeguard that closes the gap all the other layers leave open when they individually fail.

Performance Considerations and Indexing Strategies

RLS adds a predicate to every qualifying query. The query planner treats this predicate identically to an explicit WHERE clause — which means it benefits from indexes exactly as an explicit WHERE clause would, and it carries a performance penalty if the relevant index is missing, exactly as a missing WHERE clause index would.

Required Index: tenant_id on Every RLS-Protected Table

Every table with an RLS policy filtering on tenant_id must have an index on that column. Without it, every query against the table triggers a full sequential scan, filtered by the policy predicate. At scale — a telehealth platform with hundreds of thousands of patient records — this is immediately catastrophic for performance.

PostgreSQL — Index Strategy for RLS-Protected Tables
-- Required: simple index on tenant_id for all RLS-protected tables
CREATE INDEX idx_patients_tenant_id
  ON patients (tenant_id);

-- Better: composite index for common query patterns
-- (tenant_id first — the RLS predicate is always present)
CREATE INDEX idx_patients_tenant_status
  ON patients (tenant_id, status);

CREATE INDEX idx_patients_tenant_created
  ON patients (tenant_id, created_at DESC);

-- Partial index for active patients only (high-cardinality optimization)
CREATE INDEX idx_patients_active_tenant
  ON patients (tenant_id, patient_id)
  WHERE status = 'active';

Query Plan Verification

After enabling RLS, run EXPLAIN ANALYZE on your most frequent queries to verify the query planner is using the expected indexes. The RLS predicate should appear in the query plan as an index scan condition, not a filter applied after a sequential scan.

PostgreSQL — Verifying RLS Policy Appears in Query Plan
-- Set the tenant context first
SET app.current_tenant = 'clinic_abc';

-- Verify the query plan includes an index scan on tenant_id
EXPLAIN ANALYZE
SELECT patient_id, first_name, last_name, dob
FROM patients
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 50;

-- Expected output includes:
-- Index Scan using idx_patients_tenant_status on patients
-- Index Cond: (tenant_id = current_setting(...) AND status = 'active')
-- NOT: Seq Scan with Filter: (tenant_id = current_setting(...))

Connection Pool Considerations

RLS policies that call current_setting() are evaluated per-row on the database server. The function call overhead is minimal for typical OLTP query patterns. For high-frequency endpoints that query millions of rows (analytics, reporting), consider materializing tenant-scoped views or using read replicas with pre-filtered data for reporting workloads — keeping the transactional RLS path for writes and sensitive reads.

Implementation Patterns for Healthcare Platforms

tenant_id on Every Clinical Table

The foundational schema requirement: every table that stores PHI must have a tenant_id column. This is non-negotiable. Adding tenant_id retroactively to a table that was built without it is an expensive, high-risk migration. Build it in from day zero.

PostgreSQL — Baseline Schema Pattern for Healthcare Tenancy
CREATE TABLE patients (
  patient_id     UUID        DEFAULT gen_random_uuid() PRIMARY KEY,
  tenant_id      TEXT        NOT NULL,   -- always present, always indexed
  first_name     BYTEA       NOT NULL,   -- encrypted at application layer
  last_name      BYTEA       NOT NULL,
  dob            BYTEA,                  -- HIPAA identifier, encrypted
  status         TEXT        NOT NULL DEFAULT 'active',
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now(),

  CONSTRAINT fk_patients_tenant
    FOREIGN KEY (tenant_id) REFERENCES tenants(tenant_id)
    ON DELETE RESTRICT
);

CREATE INDEX idx_patients_tenant_id ON patients (tenant_id);
ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE patients FORCE ROW LEVEL SECURITY;

CREATE POLICY patients_tenant_isolation
  ON patients AS RESTRICTIVE
  USING (tenant_id = current_setting('app.current_tenant', TRUE));

Middleware-Level Tenant Context Setting

Rather than setting tenant context in every individual query function, centralize it in request middleware. Every inbound request carries a JWT or session token from which the tenant identifier is extracted. The middleware sets the tenant context for the duration of the database connection used by that request.

Python (FastAPI) — Tenant Context Middleware
from fastapi import Request, HTTPException from app.core.auth import decode_jwt async def set_tenant_context(request: Request, call_next): # Extract tenant from verified JWT claim token = request.headers.get("Authorization", "").removeprefix("Bearer ") if not token: return await call_next(request) claims = decode_jwt(token) tenant_id = claims.get("tenant_id") if not tenant_id: raise HTTPException(status_code=403, detail="Missing tenant claim") # Store on request state — used by database layer to set LOCAL request.state.tenant_id = tenant_id return await call_next(request) # In the database connection context manager: async def get_tenant_conn(pool: asyncpg.Pool, tenant_id: str): conn = await pool.acquire() try: async with conn.transaction(): await conn.execute("SET LOCAL app.current_tenant = $1", tenant_id) yield conn finally: await pool.release(conn)

Testing RLS Policies

RLS policies must be tested in isolation from the application layer. The goal is to verify that the database engine itself enforces the boundaries — not that the application happens to send the right queries.

Automated Policy Tests

Write database-level tests that connect as the application role (not a superuser), set a tenant context, and verify that queries return only the expected rows. Then set a different tenant context and verify that the first tenant's rows are not returned.

Python (pytest + asyncpg) — RLS Policy Test
import pytest import asyncpg async def test_rls_tenant_isolation(db_pool): # Insert test patients for two different tenants async with db_pool.acquire() as conn: await conn.execute(""" INSERT INTO patients (tenant_id, first_name, last_name) VALUES ('clinic_a', 'Alice_encrypted', 'Smith_encrypted'), ('clinic_b', 'Bob_encrypted', 'Jones_encrypted') """) # Connect as application role (not superuser) app_conn = await asyncpg.connect(dsn=APP_ROLE_DSN) try: async with app_conn.transaction(): # Set context to clinic_a await app_conn.execute("SET LOCAL app.current_tenant = 'clinic_a'") rows = await app_conn.fetch("SELECT tenant_id FROM patients") # Assert: only clinic_a rows returned tenant_ids = {r['tenant_id'] for r in rows} assert tenant_ids == {'clinic_a'}, f"RLS FAILURE: got {tenant_ids}" async with app_conn.transaction(): # Set context to clinic_b await app_conn.execute("SET LOCAL app.current_tenant = 'clinic_b'") rows = await app_conn.fetch("SELECT tenant_id FROM patients") # Assert: only clinic_b rows returned tenant_ids = {r['tenant_id'] for r in rows} assert tenant_ids == {'clinic_b'}, f"RLS FAILURE: got {tenant_ids}" finally: await app_conn.close()

Negative Tests: Verify the Absence of Cross-Tenant Data

Standard positive tests verify you can see your own data. The equally important negative tests verify you cannot see other tenants' data. These should be included in your CI pipeline and gated on every database schema migration. A migration that accidentally alters an RLS policy must be caught in automated testing, not in production.

Common Implementation Mistakes

Healthcare engineering teams implementing RLS for the first time make a predictable set of mistakes. Each one either silently disables isolation or creates a false sense of security.

Mistake 1: Using SET Instead of SET LOCAL

Session-scoped SET persists for the lifetime of the database connection. In a connection pool, connections are reused across requests. A request that sets SET app.current_tenant = 'clinic_a' and then returns the connection to the pool leaves clinic_a as the tenant context for the next request that acquires that connection — regardless of which tenant that request belongs to. Always use SET LOCAL inside a transaction.

Mistake 2: Not Applying FORCE ROW LEVEL SECURITY

By default, table owners and superusers bypass RLS policies. If your application connects as the table owner role (common in development setups that were promoted to production), RLS is silently not applied. ALTER TABLE patients FORCE ROW LEVEL SECURITY applies policies to the table owner as well, removing this bypass.

Mistake 3: Missing the Application Role Grant

RLS policies are defined with USING expressions that reference current_setting('app.current_tenant'). If the application database role does not have SELECT permission on the table, the policy never gets a chance to evaluate — the query fails with a permission error. Conversely, granting SELECT to the application role without defining any RLS policy results in the application role seeing all rows. Both permission and policy must be configured together.

Mistake 4: Superuser Application Credentials

The application database user must never be a PostgreSQL superuser. Superusers bypass RLS unconditionally. Many development environments use a superuser for convenience; if those credentials are promoted to production, RLS provides zero isolation. Use a least-privilege application role and grant table permissions explicitly.

Mistake 5: Forgetting Junction Tables and Audit Tables

PHI flows through junction tables (patient-appointment joins, patient-prescription joins) and audit log tables. If RLS is applied to the primary patient table but not the junction tables, a query against the junction table can reveal which patients (by ID) exist in other tenants, even if the patient names are inaccessible. Apply RLS to every table in the PHI data model.

Audit Trail Risk

Audit log tables are particularly easy to forget. If your platform logs "user X accessed patient Y" and the audit table has no RLS policy, a query to the audit table returns the complete audit history across all tenants — including the patient IDs and user IDs of every other tenant. Audit tables must carry tenant_id and must have RLS policies exactly like any other PHI-adjacent table.

RLS + Field-Level Encryption Together

RLS and field-level encryption are complementary controls that address different threat vectors. Deploying both is the correct approach for HIPAA-regulated multi-tenant platforms.

RLS protects against query-layer threats: SQL injection, ORM bugs, developer errors, unauthorized direct database connections. It ensures that queries issued by the wrong tenant context return zero rows.

Field-level encryption protects against storage-layer threats: compromised database backups, unauthorized direct filesystem access, database snapshots shared with the wrong party, internal employees with direct database access who bypass the application layer. Even if an attacker obtains a complete database dump, encrypted PHI fields are unreadable without the per-tenant decryption key. For a full technical guide to implementing this layer, see our article on field-level AES-256 encryption for telehealth. When serving patient portals, per-tenant encryption keys for patient portals explains how to scope key management to individual clinic boundaries.

PostgreSQL — Schema with Field-Level Encryption + RLS
-- PHI fields stored as BYTEA (encrypted ciphertext) -- RLS prevents cross-tenant queries; encryption protects the ciphertext itself CREATE TABLE lab_results ( result_id UUID DEFAULT gen_random_uuid() PRIMARY KEY, tenant_id TEXT NOT NULL, patient_id UUID NOT NULL, test_type TEXT NOT NULL, -- not PHI, plaintext OK result_value BYTEA NOT NULL, -- AES-256-GCM encrypted, per-tenant key reference_range BYTEA, -- encrypted collected_at TIMESTAMPTZ NOT NULL, key_version INTEGER NOT NULL DEFAULT 1 -- for key rotation ); ALTER TABLE lab_results ENABLE ROW LEVEL SECURITY; ALTER TABLE lab_results FORCE ROW LEVEL SECURITY; CREATE POLICY lab_results_tenant_isolation ON lab_results AS RESTRICTIVE USING (tenant_id = current_setting('app.current_tenant', TRUE));

In the LUKE platform, per-tenant AES-256-GCM keys are stored in a KMS (key management service), never in the database. The application fetches the tenant key from the KMS on startup (cached in memory, never persisted locally), uses it to encrypt PHI before writing and to decrypt after reading. A breach of the database storage returns only ciphertext; a breach of the application connection pool is bounded by RLS to the current tenant's rows.

How RLS Prevents the Most Common Healthcare Data Breaches

Healthcare data breaches follow predictable patterns. The 2024–2025 OCR enforcement period and breach notification wall-of-shame entries point to a consistent set of root causes. RLS directly prevents several of the most common.

Breach Vector How It Occurs RLS Mitigation Additional Control Needed
Cross-tenant API exposure Missing WHERE clause returns all-tenant data through a patient listing endpoint Fully prevented — RLS filters at DB level API-level auth, rate limiting
SQL injection Injected SQL bypasses app-layer WHERE clauses Contained to current tenant Parameterized queries, WAF
ORM eager-loading bug Join or prefetch returns related records without tenant filter Fully prevented ORM version pinning, testing
Insider threat (direct DB access) Employee connects directly via psql and runs unrestricted queries Partially mitigated — depends on role used Least-privilege DB roles, access logging, VPN
Database backup exposure Backup accessed by unauthorized party Not prevented — RLS is runtime-only Field-level encryption, backup encryption, KMS
Session fixation / JWT theft Attacker acquires valid session token for one tenant, attempts lateral movement Fully prevented — stolen token still scoped to victim tenant only Short JWT TTLs, refresh token rotation

The table highlights an important nuance: RLS is a runtime enforcement mechanism. It protects against threats that occur through the database query interface — application bugs, injection, direct connections. It does not protect against threats at the storage layer (someone obtaining the raw database files or a backup). That is precisely why field-level encryption is the complementary control: it addresses the threat class that RLS cannot.

Together, the two controls cover the complete threat surface for PHI data at rest and at query time. Neither alone is sufficient. Both together make the data set of patient records effectively unusable to any unauthorized party, whether they access it through a compromised application session, a direct database connection, or a stolen backup. Completing the security stack requires hash-chained audit trails to record and prove what was accessed — a required HIPAA implementation specification under 45 CFR 164.312(b).

LUKE Health Is Built on These Principles

LUKE is the telehealth infrastructure platform designed for specialty medicine clinics — peptide, TRT, HRT, and metabolic health practices. Every LUKE deployment runs on PostgreSQL with RESTRICTIVE RLS policies and per-tenant AES-256-GCM field encryption, from day one. Your patient data cannot cross tenant boundaries. That is not a policy statement; it is a database-engine guarantee.

PostgreSQL RLS on all PHI tables Per-tenant field-level encryption HIPAA BAA included Immutable audit trails SOC 2 Type II ready
Book a Security Architecture Review See the full LUKE platform

Frequently Asked Questions

What is row-level security (RLS) and why does it matter for multi-tenant telehealth?

Row-level security is a database-enforced mechanism that automatically filters query results so that each session can only see rows it is authorized to access. In a multi-tenant telehealth context, this means a query run in the context of Clinic A will never return records belonging to Clinic B, regardless of what SQL is executed. This matters for telehealth because it creates a mandatory second line of defense: even if application code contains a bug, an ORM misconfiguration, or a successful SQL injection, the database engine itself refuses to return cross-tenant patient data. For HIPAA-regulated platforms, this is the difference between a recoverable developer error and a reportable data breach.

Why is application-layer filtering alone not sufficient for HIPAA multi-tenant isolation?

Application-layer filtering — adding WHERE tenant_id = :tenant_id to every query — relies entirely on developers writing every query correctly, every time, forever. This is insufficient for several reasons: SQL injection can bypass WHERE clauses entirely; ORM bugs (eager loading, join misconfiguration) have historically exposed cross-tenant data at major SaaS companies; developer errors such as a forgotten WHERE clause on a new endpoint will not be caught until a patient reports seeing another patient's data; and raw SQL maintenance across hundreds of queries creates compounding risk as a codebase grows. RLS moves the enforcement to the database layer where it cannot be bypassed by application code.

Does PostgreSQL row-level security hurt query performance?

RLS adds a predicate to every query, and like any WHERE clause, that predicate must be supported by an index to perform efficiently. For healthcare platforms, every table subject to RLS must have an index on the tenant_id column (or composite indexes for common query patterns). With proper indexing, RLS overhead is negligible — the query planner treats RLS predicates identically to explicit WHERE clauses. The performance cost of missing indexes is not caused by RLS itself but by the same issue that would affect equivalent explicit WHERE clauses. Partial indexes can further optimize high-cardinality tenant tables.

How do you set the tenant context for PostgreSQL row-level security?

The standard approach is to set a session-local configuration variable before executing any queries. In PostgreSQL, this is done with SET LOCAL app.current_tenant = 'clinic_abc' within a transaction. The RLS policy references this via current_setting('app.current_tenant'). In application code using a connection pool (asyncpg, psycopg2, SQLAlchemy), you must set this variable at the start of every transaction — not once at connection time — because pooled connections are reused across requests. Failure to reset the tenant context per-transaction is one of the most common RLS implementation mistakes in production healthcare systems.

What is the difference between PERMISSIVE and RESTRICTIVE RLS policies in PostgreSQL?

PERMISSIVE policies (the default) are combined with OR: a row is returned if it passes any PERMISSIVE policy. RESTRICTIVE policies are combined with AND: a row is returned only if it passes all RESTRICTIVE policies. For multi-tenant healthcare platforms, you typically want RESTRICTIVE policies as your baseline tenant isolation layer — this means that even if you add feature-specific PERMISSIVE policies later (for example, allowing certain admin roles to see all tenants), the tenant isolation check remains a hard requirement that all queries must satisfy. Using PERMISSIVE as your only policy type can inadvertently open cross-tenant access as your policy set grows.

How does row-level security complement field-level encryption in a HIPAA telehealth platform?

RLS and field-level encryption address different threat vectors and should be used together. RLS prevents unauthorized queries from returning PHI rows at all — it protects against application bugs, injection attacks, and misconfigured access control. Field-level encryption (AES-256-GCM per field, with per-tenant keys) protects against database-layer breaches: if an attacker gains direct access to the database files or backups, encrypted fields are unreadable without the tenant's key. Together, they create layered protection: RLS ensures you can't query across tenants, and encryption ensures that even if underlying data is accessed outside the application, PHI remains protected. HIPAA's Security Rule defense-in-depth principle requires both layers.