When data lives in the same table but must be seen by different eyes, the answer isn’t “split the table” – it’s “filter the rows.” Row‑Level Security (RLS) gives you that filter, letting you enforce fine‑grained access control once, at the database layer, while letting the rest of your application stay blissfully unaware of the underlying guardrails. In a world where bee‑research datasets can contain millions of observations, where AI agents must respect privacy policies without being constantly reminded, and where multi‑tenant platforms need to protect each tenant’s secrets, RLS is a quiet hero that prevents data leaks before they even happen.
In this guide we walk through every step required to create, test, and maintain row‑level policies that adapt to the user context you provide. We’ll cover the theory, the concrete syntax for the most common databases, performance tricks, auditing, and two real‑world case studies – one from a bee‑conservation platform and one from an autonomous AI agent framework. By the end you’ll have a ready‑to‑deploy blueprint that you can copy‑paste into your own projects, whether you’re protecting hive health records, user‑generated content, or the training data of a self‑governing AI.
Understanding Row‑Level Security Basics
Row‑Level Security, sometimes called row‑level access control or row‑filtering policies, is a database‑native mechanism that automatically adds a predicate to every query that touches a protected table. Think of it as a permanent WHERE clause that the database injects for you, based on the current user’s identity or any other session information you expose.
| Feature | Traditional Approach | RLS Approach |
|---|---|---|
| Enforcement point | Application code (often duplicated) | Database engine |
| Risk of bypass | High – a developer can forget a check | Low – engine applies predicate to every query |
| Performance impact | Variable (extra joins, logic) | Predictable (single predicate, indexed) |
| Auditability | Hard – scattered across services | Central – visible in system tables |
Why RLS matters now:
- Compliance: GDPR, CCPA, and HIPAA all require “need‑to‑know” access. A 2023 audit of 1,200 U.S. companies found that 32 % of data breaches involved privileged users accessing rows they shouldn’t have.¹
- Scale: Modern SaaS platforms often host 10‑100 million rows per tenant. Splitting tables per tenant inflates storage by up to 30 % and complicates migrations.
- AI safety: Self‑governing AI agents that query training data must be constrained by the same policies that humans are, otherwise they can unintentionally leak sensitive samples.
RLS works best when you standardize the user context (e.g., a user_id, a role, or a tenant_id) and store it in a session variable that the database can read. The rest of this guide shows you how to do exactly that.
Core Components: Policies, Predicates, and Context
1. Policies
A policy is a named rule that tells the database when and how to apply a predicate. Most engines support three basic actions:
| Action | Meaning |
|---|---|
| SELECT | Filter rows returned by queries |
| INSERT | Validate rows being added |
| UPDATE / DELETE | Restrict modifications to rows you own |
You can create multiple policies per table, and they are combined with OR logic (any policy that evaluates true grants access). This makes it easy to grant a developer a “read‑any” policy while keeping “owner‑only” for regular users.
2. Predicates
A predicate is the actual Boolean expression that decides if a row belongs to the current user. Typical predicates reference:
- Session variables (
current_user,app.tenant_id,jwt.claims.role) - Static columns (
owner_id,tenant_id) - Functions (
has_permission(user_id, 'view_all'))
Example predicate in PostgreSQL syntax:
owner_id = current_setting('app.user_id')::int
OR current_setting('app.role') = 'admin'
3. Context
The context is the data you feed into the database for each connection. Common sources:
| Source | Typical Use |
|---|---|
| JWT claims | Pass user ID, role, tenant ID from an OAuth token |
| Application‑level session variables | Set after login via SET app.user_id = 42; |
| External authentication services | Use PostgreSQL’s pg_ident.conf or SQL Server’s EXECUTE AS |
When you design RLS, you must decide what context you’ll expose and how you’ll protect it (e.g., by using signed JWTs). The next sections walk through concrete implementations.
Designing RLS for Multi‑Tenant Applications
A multi‑tenant SaaS stores data for dozens or thousands of customers in a single database. The most common pattern is a tenant_id column on every shared table. RLS can enforce that each tenant only sees its own rows, without the need for separate schemas.
Step‑by‑Step Blueprint
- Add a
tenant_idcolumn to every table that holds tenant data.
ALTER TABLE observations ADD COLUMN tenant_id UUID NOT NULL;
- Create a session variable that holds the current tenant’s UUID.
SET app.tenant_id = 'e3f7c2d2‑9d5a‑4d12‑a6b5‑e1c9f4b9a1c3';
- Write a policy that matches the column to the session variable.
CREATE POLICY tenant_isolation ON observations
USING (tenant_id = current_setting('app.tenant_id')::uuid);
- Enable RLS on the table.
ALTER TABLE observations ENABLE ROW LEVEL SECURITY;
- Test with two tenants.
-- Tenant A
SET app.tenant_id = 'a111…';
SELECT COUNT(*) FROM observations; -- returns only A’s rows
-- Tenant B
SET app.tenant_id = 'b222…';
SELECT COUNT(*) FROM observations; -- returns only B’s rows
Real‑World Numbers
A large bee‑research platform hosted 12 million observation rows across 450 research partners. By applying RLS, they avoided ≈ 2 TB of duplicated data (the same size as three full backups) and reduced the number of required database shards from 12 to 2, cutting operational costs by 27 % (as measured in a 2022 internal cost analysis).²
Edge Cases to Plan For
| Situation | Solution |
|---|---|
| Cross‑tenant reporting (e.g., aggregate across all tenants) | Create a super‑admin role that bypasses RLS, or use a separate reporting schema with its own privileges. |
| Tenant migration (moving rows to a new tenant) | Temporarily grant the migration service a policy that allows UPDATE on tenant_id for the specific rows. |
| Tenant‑wide read‑only users | Add a role‑based policy: current_setting('app.role') = 'viewer' that permits SELECT but blocks INSERT/UPDATE/DELETE. |
Implementing RLS in Popular Databases
Below we show concrete commands for the three most widely used relational engines. The patterns are interchangeable; pick the syntax that matches your stack.
PostgreSQL (v14+)
PostgreSQL introduced RLS in version 9.5 and has become the most feature‑complete implementation.
-- 1️⃣ Enable the extension (optional for helper functions)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 2️⃣ Add session variables (via `SET`)
SET app.user_id = '42';
SET app.role = 'researcher';
-- 3️⃣ Create a policy
CREATE POLICY obs_owner_policy ON observations
USING (owner_id = current_setting('app.user_id')::int);
-- 4️⃣ Enable RLS
ALTER TABLE observations ENABLE ROW LEVEL SECURITY;
-- 5️⃣ OPTIONAL: Add a second policy for admins
CREATE POLICY admin_all_policy ON observations
USING (current_setting('app.role') = 'admin');
Testing:
EXPLAIN ANALYZE SELECT * FROM observations WHERE id = 1;
-- The plan will show a Filter: (owner_id = 42) OR (current_setting('app.role') = 'admin')
Microsoft SQL Server (2016+)
SQL Server uses security predicates attached to security policies.
-- 1️⃣ Create a function that returns a predicate
CREATE FUNCTION dbo.fn_rls_observations(@owner_id int)
RETURNS TABLE WITH SCHEMABINDING
AS
RETURN SELECT 1 AS fn_result
WHERE @owner_id = CONVERT(int, SESSION_CONTEXT(N'user_id'))
OR SESSION_CONTEXT(N'role') = N'admin';
-- 2️⃣ Create the security policy
CREATE SECURITY POLICY dbo.ObservationsRlsPolicy
ADD FILTER PREDICATE dbo.fn_rls_observations(owner_id) ON dbo.observations
WITH (STATE = ON);
Setting context (from the client driver):
EXEC sp_set_session_context @key = N'user_id', @value = 42;
EXEC sp_set_session_context @key = N'role', @value = N'researcher';
MySQL (8.0) – Using Views and Definers
MySQL does not have native RLS, but you can emulate it with security‑definer views.
CREATE TABLE observations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
owner_id INT NOT NULL,
data JSON,
tenant_id CHAR(36) NOT NULL
);
-- 1️⃣ Create a view that filters rows
CREATE VIEW v_observations
AS SELECT *
FROM observations
WHERE owner_id = @app_user_id
OR @app_role = 'admin';
-- 2️⃣ Grant SELECT on the view, not the base table
GRANT SELECT ON v_observations TO 'app_user'@'%';
REVOKE SELECT ON observations FROM 'app_user'@'%';
When the application connects, it runs SET @app_user_id = 42; SET @app_role = 'researcher'; before any query.
Snowflake
Snowflake offers Row Access Policies that can be attached to columns.
CREATE ROW ACCESS POLICY obs_policy
AS (owner_id INT) RETURNS BOOLEAN ->
owner_id = CURRENT_SESSION().USER_ID
OR CURRENT_SESSION().ROLE = 'ADMIN';
ALTER TABLE observations
MODIFY COLUMN owner_id SET ROW ACCESS POLICY obs_policy;
Performance tip: Snowflake pushes the predicate down to the storage layer, so you get the same cost‑based optimization as with native filters.
Managing User Context: From JWT to Session Variables
The security of RLS hinges on trustworthy context. If a malicious client can set app.user_id arbitrarily, they can bypass the filter. Below are proven patterns for feeding context safely.
1. JWT Claims (Recommended for APIs)
- Issue a signed JWT from your Auth server (e.g., Auth0, Keycloak). Include fields:
sub(user ID),role,tenant_id. - Validate the token on each request (library‑level).
- Extract the claims and set session variables in a single DB round‑trip.
# Python example using psycopg2
def set_context(conn, token):
payload = jwt.decode(token, PUBLIC_KEY, algorithms=['RS256'])
with conn.cursor() as cur:
cur.execute("SET app.user_id = %s, app.role = %s, app.tenant_id = %s",
(payload['sub'], payload['role'], payload['tenant_id']))
Why it works: The JWT is cryptographically signed; the database never sees the raw token, only the vetted values.
2. Application‑Level Session Variables
If you already have a traditional login flow (e.g., username/password), you can store the user’s ID in the connection pool.
// Go example with pgx
conn, _ := pgx.Connect(context.Background(), dsn)
_, err := conn.Exec(context.Background(),
"SET app.user_id = $1, app.role = $2, app.tenant_id = $3",
user.ID, user.Role, user.TenantID)
3. External Authentication (Kerberos, LDAP)
SQL Server’s EXECUTE AS USER can map a Windows domain user to a session context automatically. This is useful when you have self‑governing AI agents that run under service accounts.
EXECUTE AS USER = 'ai.agent.service';
-- The login triggers a trigger that sets SESSION_CONTEXT('role') = 'agent';
4. Protecting the Context
| Threat | Mitigation |
|---|---|
SQL injection via SET | Use parameterized queries; never concatenate raw strings. |
| Replay attacks | Include a short exp claim in JWTs (e.g., 5 minutes). |
| Privilege escalation | Keep a separate “admin bypass” role that is only granted to service accounts, not to end‑users. |
Testing and Auditing RLS Policies
RLS can be invisible until a bug surfaces. Systematic testing and auditing keep you from accidental data exposure.
1. Unit Tests with Fixtures
Create a test schema that mirrors production tables, enable RLS, then run a suite of queries for each role.
-- In a PostgreSQL test database
SET app.user_id = 101;
SET app.role = 'researcher';
SELECT COUNT(*) FROM observations; -- Expect only rows where owner_id = 101
SET app.role = 'admin';
SELECT COUNT(*) FROM observations; -- Expect total row count
Automate with a CI pipeline (GitHub Actions, GitLab CI) that spins up a Docker container for the DB and runs a script like the above.
2. Using EXPLAIN to Verify Predicate Injection
The EXPLAIN output shows the filter expression added by RLS. If you see a Filter: (false) on a table you expect to be visible, your context is missing.
EXPLAIN SELECT * FROM observations WHERE id = 5;
-- Look for: Filter: ((owner_id = 42) OR (current_setting('app.role') = 'admin'))
3. Auditing with System Views
| DB | Auditing Table |
|---|---|
| PostgreSQL | pg_policy + pg_stat_activity |
| SQL Server | sys.security_policies + sys.dm_exec_sessions |
| Snowflake | ACCOUNT_USAGE.ROW_ACCESS_POLICIES |
You can query these tables to see who accessed which rows and when.
SELECT usename, query, start_time
FROM pg_stat_activity
WHERE query LIKE '%observations%';
4. Log‑Based Alerts
Integrate with a SIEM (e.g., Splunk, Elastic) to trigger alerts when a non‑admin user accesses a row with an unexpected owner_id. A sample alert rule:
IF event.type = "SELECT"
AND event.table = "observations"
AND NOT (user.role = "admin" OR row.owner_id = user.id)
THEN raise "Potential RLS bypass"
Performance Considerations and Optimization
RLS adds a predicate to every query, but with proper indexing the overhead is negligible.
1. Index the Filter Columns
If you filter on owner_id and tenant_id, create a composite index that matches the predicate order.
CREATE INDEX idx_observations_owner_tenant
ON observations (owner_id, tenant_id);
Benchmark (PostgreSQL 15, on a 12 GB table of 15 M rows):
| Scenario | Avg. Latency (ms) | CPU % |
|---|---|---|
| No RLS, simple SELECT | 12 | 4 |
RLS with indexed owner_id | 14 | 5 |
| RLS without index | 48 | 18 |
The indexed case adds ≈ 2 ms overhead – acceptable for most web APIs.
2. Avoid Function Calls in Predicates
Calling a PL/pgSQL function in the predicate can prevent index usage. Prefer inline expressions or IMMUTABLE functions.
-- Good
USING (owner_id = current_setting('app.user_id')::int)
-- Bad (slow)
USING (owner_id = get_user_id())
3. Partitioning for Massive Datasets
When tables exceed 100 M rows, consider partitioning by tenant_id. RLS still works, and the planner can prune partitions early, reducing I/O.
CREATE TABLE observations (
id BIGINT,
owner_id INT,
tenant_id UUID,
data JSONB,
PRIMARY KEY (tenant_id, id)
) PARTITION BY HASH (tenant_id);
4. Cache Session Variables
In PostgreSQL, current_setting is cheap, but if you call it millions of times per second, you can cache the value in a generated column that is materialized on insert.
ALTER TABLE observations
ADD COLUMN user_id_cached INT GENERATED ALWAYS AS (owner_id) STORED;
Then your policy can reference user_id_cached, which the planner treats like a normal column.
Real‑World Case Study: Bee Data Platform
Background – The Apiary platform hosts a global dataset of honey‑bee health observations, contributed by over 300 research teams. Each team uploads CSV files containing fields like hive_id, temperature, varroa_count, and observation_date. The platform must guarantee that:
- Team A cannot see Team B’s raw data.
- National regulators can view aggregated statistics across all teams, but not the underlying raw rows.
- AI agents that suggest interventions must only read rows they are authorized to, based on the role assigned by the platform’s governance model.
Implementation Steps
| Step | Action | Code Snippet |
|---|---|---|
| 1️⃣ | Add team_id and is_public columns to all core tables. | ALTER TABLE hive_observations ADD COLUMN team_id UUID NOT NULL; |
| 2️⃣ | Create a policy that allows owners or public rows. | ``sql CREATE POLICY team_policy ON hive_observations USING (team_id = current_setting('app.team_id')::uuid OR is_public = true); `` |
| 3️⃣ | Enable RLS. | ALTER TABLE hive_observations ENABLE ROW LEVEL SECURITY; |
| 4️⃣ | Insert a regulator role that bypasses the team_id filter. | ``sql CREATE POLICY regulator_policy ON hive_observations USING (current_setting('app.role') = 'regulator'); `` |
| 5️⃣ | Set context from JWT after login. | See the JWT section above. |
| 6️⃣ | Test with two teams. | Run queries with SET app.team_id = 'team‑a‑uuid'; and verify row counts. |
Numbers
- Rows stored: 58 M (≈ 12 TB compressed)
- Average query latency (with RLS): 18 ms (vs. 16 ms without RLS) – a 12 % increase, deemed acceptable.
- Security incidents: 0 data leaks in the 18‑month period after RLS deployment, compared to 3 incidents in the prior 2 years (all unrelated to row-level access).
Integration with AI Agents
The platform’s AI assistant, BeeMind, runs as a microservice with the role agent. Its policy:
CREATE POLICY agent_read_policy ON hive_observations
USING (current_setting('app.role') = 'agent'
AND observation_date >= now() - interval '30 days');
This ensures the agent only sees recent data (preventing it from memorizing historic patterns that could be reverse‑engineered). The policy is enforced automatically whenever BeeMind issues a SELECT, without any extra code in the agent.
Integrating RLS with AI Agents and Self‑Governance
Self‑governing AI agents – think of a swarm of bots that negotiate data access among themselves – need hard guarantees that they cannot overstep their data boundaries. RLS provides a legal layer that the agents can query as part of their decision‑making.
1. Policy‑Driven Decision Engine
An AI agent can first ask the database “what am I allowed to see?” by querying the pg_policy catalog (PostgreSQL) or the sys.security_policies view (SQL Server). The response informs the agent’s action planner.
SELECT policy_name, command, using_expression
FROM pg_policies
WHERE tablename = 'observations';
The agent parses using_expression and determines whether the requested operation satisfies the predicate. If not, it rejects the request before even forming the SELECT.
2. Auditable Action Logs
When an AI agent performs a data‑driven decision (e.g., “recommend pesticide X”), the platform logs:
| Field | Value |
|---|---|
agent_id | bee‑mind‑v2 |
policy_used | agent_read_policy |
row_count | 1245 |
timestamp | 2026‑06‑15T08:32:11Z |
These logs feed into a self‑governance dashboard where stakeholders can see whether agents respect their data contracts.
3. Example: Autonomous Hive‑Health Bot
def fetch_recent_observations(conn, team_uuid):
# Context is set via JWT in the request
cur = conn.cursor()
cur.execute("""SELECT hive_id, temperature, varroa_count
FROM hive_observations
WHERE team_id = %s
AND observation_date >= now() - interval '7 days'""",
(team_uuid,))
return cur.fetchall()
Even if the Python code forgot to filter by team_id, the RLS policy still enforces it because the predicate (team_id = current_setting('app.team_id')::uuid) is automatically added. This defensive guarantee is exactly why RLS is a natural fit for AI agents: the agents can be stateless and still stay within boundaries.
Common Pitfalls and Best Practices
| Pitfall | Why It Happens | Remedy |
|---|---|---|
Missing SET of session variables | Developers forget to set app.user_id after authentication. | Automate the SET step in a DB connection wrapper; enforce via code reviews. |
Over‑broad policies (e.g., TRUE) | Copy‑pasting a template and forgetting to replace placeholder columns. | Use static analysis to detect policies that always evaluate to TRUE. |
| Index mismatch | Predicate column not indexed → full table scans. | Run EXPLAIN ANALYZE on representative queries; add composite indexes. |
| Policy ordering confusion | Assuming policies are evaluated in order (they’re OR‑combined). | Document each policy’s purpose; keep the number of policies low. |
Leaking via EXPLAIN | Some DB tools expose the injected predicate to users without permission. | Restrict EXPLAIN access to privileged roles only. |
| Cross‑tenant reporting without proper guard | Using a reporting view that bypasses RLS. | Create a dedicated “reporting role” with its own policy that only allows aggregates. |
| Stale JWT claims | Tokens not refreshed cause app.tenant_id to be out‑of‑date. | Enforce short expiration (5–10 min) and implement silent refresh. |
Checklist Before Going Live
- [ ] All tables that store tenant‑ or user‑specific data have RLS enabled.
- [ ] Every connection sets all required session variables (
user_id,role,tenant_id). - [ ] Unit tests cover at least one user of each role (admin, researcher, viewer).
- [ ] Indexes exist on every column referenced in a predicate.
- [ ] Auditing is turned on and logs are shipped to a SIEM.
- [ ] Documentation (including this guide) is linked from the platform’s security policy page (
[[security-policies]]).
Why it matters
Row‑Level Security is not a luxury feature; it’s a foundational safeguard that turns a database from a passive data store into an active gatekeeper. By moving access control into the engine, you eliminate a whole class of bugs that arise from duplicated checks in application code, you reduce operational overhead, and you give AI agents a reliable contract they can trust. For the Apiary community—where every honey‑bee observation can influence conservation decisions and where autonomous agents help allocate resources—RLS ensures that the right eyes see the right data, every time. This means more trustworthy research, fewer compliance headaches, and a safer, more collaborative ecosystem for humans and AI alike.