Row-Level Security
Isolate data per tenant using automatic WHERE clause injection based on user claims.
This page covers content for workspace admins (setting up RLS policies), operators (auth mode compatibility and claim resolution), and end users (understanding data access boundaries). Jump to the section relevant to your role, or read end-to-end for the full picture.
Row-Level Security (RLS) injects WHERE conditions into every SQL query based on the authenticated user's claims. This ensures tenants only see their own data -- without relying on the agent to add the right filters.
RLS is fail-closed. If the user's claims are missing or cannot be resolved, the query is blocked entirely. This is by design -- silent data leaks are worse than a blocked query.
How It Works
- You define policies that map JWT claims to table columns
- When the agent generates a SQL query, Atlas resolves the claim value from the authenticated user
- Atlas injects
WHERE table.column = 'claim_value'(orIN (...)for array claims) into the query AST (not string concatenation) - The injection runs after plugin
beforeQueryhooks -- plugins cannot strip RLS conditions - Claim values are SQL-escaped (single quotes doubled) before injection
Policies support single or multi-column conditions, array-valued JWT claims (generates IN (...) instead of =), and configurable AND/OR logic between policies. The AST manipulation handles CTEs, subqueries, UNIONs, derived tables, and table aliases correctly. Custom validators (SOQL, GraphQL) bypass RLS and must enforce their own filtering.
Quick Start (Environment Variables)
The simplest setup uses three environment variables for a single-policy configuration:
ATLAS_RLS_ENABLED=true
ATLAS_RLS_COLUMN=tenant_id # Column name to add to WHERE clauses
ATLAS_RLS_CLAIM=org_id # JWT claim path — resolved from the authenticated user's tokenThis creates a wildcard policy that applies to all tables -- equivalent to:
rls: {
enabled: true,
policies: [{ tables: ["*"], column: "tenant_id", claim: "org_id" }],
}Both ATLAS_RLS_COLUMN and ATLAS_RLS_CLAIM are required when ATLAS_RLS_ENABLED=true. Atlas will fail to start if either is missing.
Advanced Configuration (atlas.config.ts)
For multi-policy setups, use the config file:
import { defineConfig } from "@atlas/api/lib/config";
export default defineConfig({
rls: {
enabled: true,
policies: [
// Wildcard — applies tenant_id filter to every table in every query
{ tables: ["*"], column: "tenant_id", claim: "org_id" },
// Target specific tables — uses a nested JWT claim path (dot-delimited)
{ tables: ["orders", "shipments"], column: "region", claim: "app_metadata.region" },
// Schema-qualified table name — the full "schema.table" must match
{ tables: ["analytics.events"], column: "workspace_id", claim: "workspace" },
],
},
});Policy Schema
Each policy uses either the single-condition shorthand (column + claim) or the multi-condition form (conditions):
| Field | Type | Description |
|---|---|---|
tables | string[] | Table names this policy applies to. Use ["*"] for all tables |
column | string | Column name to filter on (single-condition shorthand) |
claim | string | Claim path to resolve the filter value (single-condition shorthand) |
conditions | { column, claim }[] | Multiple column/claim pairs — ANDed together within this policy |
Use column+claim for single-condition policies, or conditions for multi-column policies. You cannot use both on the same policy.
RLS Config Options
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Whether RLS is active |
policies | RLSPolicy[] | [] | Array of policies |
combineWith | "and" | "or" | "and" | How to combine conditions from different policies |
Validation rules:
- At least one policy is required when
enabled: true - Column names must match
/^[a-zA-Z_][a-zA-Z0-9_]*$/ - Tables array must have at least one entry
Claim Path Resolution
Claim paths support dot-delimited access into nested JWT structures:
| Claim Path | JWT Claims | Resolved Value |
|---|---|---|
org_id | { "org_id": "acme" } | acme |
app_metadata.tenant | { "app_metadata": { "tenant": "acme" } } | acme |
custom.nested.id | { "custom": { "nested": { "id": 42 } } } | 42 |
Non-string values are coerced to strings. Array values generate IN (...) conditions (see Array Claims). If the path resolves to undefined or null, the query is blocked. Empty arrays are also blocked (fail-closed).
Array Claims
When a JWT claim resolves to an array, Atlas generates an IN (...) condition instead of =:
JWT claims:
{
"sub": "user_123",
"departments": ["engineering", "sales"]
}Config:
rls: {
enabled: true,
policies: [
{ tables: ["*"], column: "department", claim: "departments" },
],
}Injection result:
-- Agent generates:
SELECT * FROM tickets
-- After RLS injection:
SELECT * FROM tickets WHERE tickets.department IN ('engineering', 'sales')Empty arrays are blocked (fail-closed). If a user's claim resolves to [], the query is rejected to prevent accidental full-table access.
Multi-Column Policies
When a policy needs to filter on multiple columns simultaneously, use the conditions array:
rls: {
enabled: true,
policies: [
{
tables: ["orders", "shipments"],
conditions: [
{ column: "tenant_id", claim: "org_id" },
{ column: "region", claim: "app_metadata.region" },
],
},
],
}All conditions within a policy are ANDed together:
-- After injection:
SELECT * FROM orders
WHERE orders.tenant_id = 'org_acme' AND orders.region = 'us-east'OR-Logic Between Policies
By default, conditions from different policies are ANDed (all must match). Set combineWith: "or" to allow access when any policy matches:
rls: {
enabled: true,
combineWith: "or",
policies: [
// Users can access data matching their org...
{ tables: ["*"], column: "org_id", claim: "org_id" },
// ...OR data in their assigned region
{ tables: ["*"], column: "region", claim: "region" },
],
}Injection result:
-- Agent generates:
SELECT * FROM orders WHERE status = 'active'
-- After RLS injection (OR between policies):
SELECT * FROM orders
WHERE status = 'active' AND (orders.org_id = 'org_acme' OR orders.region = 'us-east')When using combineWith: "or", the OR-combined conditions are parenthesized to prevent precedence issues with existing WHERE clauses. Within each policy, multiple conditions are still ANDed.
Auth Mode Compatibility
RLS requires an authentication mode that provides user claims:
| Auth Mode | Claims Available | RLS Compatible |
|---|---|---|
none | No | No -- queries blocked |
api-key | Only via ATLAS_RLS_CLAIMS (static JSON) | Yes, with static claims |
managed | Yes (Better Auth session) | Yes |
byot | Yes (JWT claims) | Yes -- primary use case |
If ATLAS_AUTH_MODE=none and RLS is enabled, all queries will be blocked. This is intentional -- RLS without authentication has no user context to resolve claims from.
Example: Multi-Tenant SaaS
A typical setup where each user belongs to an organization:
JWT claims (from your identity provider):
{
"sub": "user_123",
"email": "alice@acme.com",
"org_id": "org_acme",
"role": "analyst"
}Atlas config:
export default defineConfig({
auth: "byot",
rls: {
enabled: true,
policies: [
{ tables: ["*"], column: "org_id", claim: "org_id" },
],
},
});What happens when Alice runs a query:
-- Agent generates:
SELECT department, COUNT(*) FROM employees GROUP BY department
-- After RLS injection:
SELECT department, COUNT(*) FROM employees WHERE employees.org_id = 'org_acme' GROUP BY departmentThe injection is transparent to the agent and the user. The agent sees the filtered results and builds its answer from there.
How Injection Works
RLS conditions are injected via AST manipulation (not string concatenation), which handles edge cases correctly:
Table Aliases
-- Before:
SELECT e.name FROM employees e JOIN departments d ON e.dept_id = d.id
-- After (policies: employees.org_id, departments.org_id):
SELECT e.name FROM employees e JOIN departments d ON e.dept_id = d.id
WHERE e.org_id = 'org_acme' AND d.org_id = 'org_acme'CTEs and Subqueries
RLS conditions are injected into each SELECT that references a filtered table, including CTE definitions, derived tables in FROM clauses, and WHERE-clause subqueries.
UNIONs
Each branch of a UNION gets its own RLS conditions independently.
Security Model
- Fail-closed -- missing user, missing claims, unresolvable claim paths, or empty array claims block the query
- Post-plugin -- RLS injection runs after plugin
beforeQueryhooks, so plugins cannot strip conditions - SQL-escaped -- claim values have single quotes doubled before injection
- AST-based -- conditions are injected into the parsed AST and regenerated, preventing injection attacks
- Scoped to SELECT -- only applies to queries that pass SQL validation (SELECT-only)
Troubleshooting
"RLS is enabled but no authenticated user is available"
Authentication is required. Check that your auth mode provides user context. See Authentication.
"RLS policy requires claim X but it is missing"
The JWT doesn't contain the expected claim. Verify:
- The claim path is correct (check spelling, case sensitivity)
- Your identity provider includes the claim in the JWT
- For nested paths like
app_metadata.tenant, ensure the full structure exists
"ATLAS_RLS_ENABLED=true requires both ATLAS_RLS_COLUMN and ATLAS_RLS_CLAIM"
Both environment variables must be set when using env-var configuration. For multi-policy setups, use atlas.config.ts instead.
Queries return no data
If RLS is working but queries return empty results, the claim value may not match any rows. Check:
ATLAS_LOG_LEVEL=debugLook for "RLS conditions injected into SQL" log messages to see the exact WHERE clauses applied to each query.
See Troubleshooting for more diagnostic steps.
For workspace admins
If you manage an Atlas workspace through the admin console, here is what you need to know about RLS:
Setting up RLS policies requires access to atlas.config.ts or the environment variables listed in Quick Start. Coordinate with your platform operator to configure policies that match your tenant column structure.
What to verify:
- Every table your users query has a tenant column (e.g.
org_id,tenant_id,workspace_id) - The JWT claims from your identity provider include the corresponding claim path
- Test with
ATLAS_LOG_LEVEL=debugto confirm injection — look for"RLS conditions injected into SQL"log messages
Admin console visibility: RLS rejections appear in Admin > Audit Log as failed queries. Filter by error messages containing "RLS" to monitor policy enforcement.
For operators
Auth mode compatibility
RLS depends on user claims being available at query time. See Auth Mode Compatibility for the full matrix. For api-key mode specifically, static claims are provided via ATLAS_RLS_CLAIMS (a JSON string parsed at startup) — suitable for service-to-service scenarios with a fixed tenant context.
Claim resolution
Claims are resolved using dot-delimited paths. See Claim Path Resolution for the full resolution rules and examples. Claim values are SQL-escaped (single quotes doubled) before injection.
Custom validator bypass
If a connection uses a custom validator (via ConnectionPluginMeta.validate), RLS injection is skipped entirely. Custom validators for SOQL, GraphQL, or other non-SQL languages must implement their own row-level filtering.
For end users
When RLS is enabled, you will only see data that belongs to your organization or tenant. This happens automatically — you do not need to add any filters to your questions.
What this means in practice:
- If you ask "Show all orders," you see only your organization's orders
- Aggregate queries (counts, sums, averages) only include your data
- The agent does not know about other tenants' data — the filtering happens at the database level
If queries return no data:
- Your account may not have the expected claim value — contact your workspace admin
- The column name in the RLS policy may not match the actual database column — this is a configuration issue, not a data issue
See Also
- Authentication — Auth mode setup (RLS requires an auth mode that provides user claims)
- Configuration — Declarative RLS policies in
atlas.config.ts - Environment Variables — Env var shorthand for a single RLS policy
- SQL Validation Pipeline — How RLS injection fits into the 7-layer validation pipeline
- Troubleshooting — Common RLS issues and fixes
Last updated on