"Row-Level Security"
Row-Level Security lets you control which rows each user can access in your database tables. When enabled, Tether automatically filters queries and validates writes based on policies you define — no changes needed in your client code.
How It Works
RLS policies are enforced at the server level whenever a request includes an end-user auth token via the x-auth-token header. This is the standard pattern when using an external OAuth provider (Clerk, Auth0, etc.) where your frontend forwards the user's JWT alongside your API key.
When RLS Is Enforced
| Auth Context | RLS Enforced? | Reason |
|---|---|---|
API key with x-auth-token |
Yes | End-user request — identity known |
API key without x-auth-token |
No | Server-side / admin operation |
| Dashboard session | No | Project owner |
| Custom functions | No | Developer-written server code |
| Direct SQL (Hrana) | No | Raw SQL cannot be reliably filtered |
| Dashboard SQL console | No | Project owner, read-only |
Enabling RLS
Via the Dashboard
- Go to your project Settings → Security
- Toggle RLS on for each table you want to protect
- Add one or more policies
When RLS is enabled on a table with no matching policies, all access is denied by default (default-deny).
Via Schema Config
You can also define RLS policies in your tether/schema.ts file, and they'll be deployed with tether deploy:
import { text, integer, defineTable } from '@tthr/schema';
export const posts = defineTable({
columns: {
title: text(),
content: text(),
userId: text(),
published: integer({ default: 0 }),
},
rls: {
enabled: true,
policies: [
{
name: 'owner_all',
operation: '*',
using: { $owner: 'userId' },
check: { $owner: 'userId' },
},
{
name: 'public_read',
operation: 'select',
role: '*',
using: {
column: 'published',
op: 'eq',
value: { $literal: 1 },
},
},
],
},
});
Schema-deployed policies are declarative — any policies not in the schema definition will be removed on deploy.
Policy Structure
Each policy has the following fields:
| Field | Description |
|---|---|
name |
Human-readable identifier (unique per table) |
operation |
select, insert, update, delete, or * (all) |
role |
* (everyone), authenticated, or anonymous |
using |
Expression that filters which existing rows are visible/targetable |
check |
Expression that validates new or updated data |
How Policies Combine
- Multiple policies for the same operation combine with OR — if any policy grants access, the row is accessible
usingapplies to reads (select) and the target rows of updates/deletescheckapplies to inserts and the new values in updates- A policy with no
checkholds new data to itsusingexpression instead, as in Postgres, so ausing: { $owner: 'userId' }policy also stops a user writing a row with someone else'suserId. An update is judged only on the columns it changes. - If no policies match the operation and role, access is denied
Expression Syntax
Policies use a structured JSON format that compiles to parameterised SQL. This prevents SQL injection and enables visual editing in the dashboard.
Simple Conditions
{
"column": "userId",
"op": "eq",
"value": { "$auth": "sub" }
}
This reads as: "the userId column must equal the authenticated user's sub (subject/ID) claim."
Operators
| Operator | Description |
|---|---|
eq |
Equal |
ne |
Not equal |
gt |
Greater than |
gte |
Greater than or equal |
lt |
Less than |
lte |
Less than or equal |
in |
In array |
notIn |
Not in array |
isNull |
Is null |
isNotNull |
Is not null |
Value References
| Reference | Description |
|---|---|
{ "$auth": "sub" } |
User ID from JWT sub claim |
{ "$auth": "email" } |
User email from JWT |
{ "$auth": "issuer" } |
JWT issuer |
{ "$auth.claims": "org_id" } |
Arbitrary JWT claim |
{ "$literal": "value" } |
Constant value |
{ "$now": true } |
Current ISO 8601 timestamp |
An $auth reference to something the user's token doesn't have (an email, or a claim that isn't there) matches nothing, with eq and ne alike. A user without an org_id claim never sees rows whose org_id is empty. To match empty values on purpose, use isNull or a literal null.
Column names in policies must be plain identifiers (letters, digits and underscores).
Compound Expressions
Combine conditions with AND / OR:
{
"AND": [
{ "column": "userId", "op": "eq", "value": { "$auth": "sub" } },
{ "column": "status", "op": "ne", "value": { "$literal": "archived" } }
]
}
{
"OR": [
{ "column": "visibility", "op": "eq", "value": { "$literal": "public" } },
{ "column": "userId", "op": "eq", "value": { "$auth": "sub" } }
]
}
Shorthands
For common patterns, use these shortcuts:
{ "$owner": "userId" }
Equivalent to userId == auth.sub — the most common pattern for user-owned data.
{ "$authenticated": true }
Allows any request with a valid JWT token.
{ "$anyone": true }
No restriction — allows all access (useful for public read policies).
Common Patterns
Owner-Only Access
Users can only read and write their own rows:
{
"name": "owner_access",
"operation": "*",
"using": { "$owner": "userId" },
"check": { "$owner": "userId" }
}
Public Read, Authenticated Write
Anyone can read, but only authenticated users can write:
// Policy 1: public read
{
"name": "public_read",
"operation": "select",
"role": "*",
"using": { "$anyone": true }
}
// Policy 2: authenticated write
{
"name": "auth_write",
"operation": "insert",
"role": "authenticated",
"check": { "$authenticated": true }
}
Multi-Tenant Isolation
Rows scoped to an organisation from the JWT:
{
"name": "tenant_isolation",
"operation": "*",
"using": {
"column": "orgId",
"op": "eq",
"value": { "$auth.claims": "org_id" }
},
"check": {
"column": "orgId",
"op": "eq",
"value": { "$auth.claims": "org_id" }
}
}
Published Content + Owner Draft Access
Published rows are visible to everyone; drafts are only visible to their owner:
{
"name": "published_or_owner",
"operation": "select",
"using": {
"OR": [
{ "column": "published", "op": "eq", "value": { "$literal": 1 } },
{ "$owner": "userId" }
]
}
}
Testing Policies
Settings → Security has a Test policies panel that shows exactly what a user would see, without writing anything:
- Pick a table.
- Enter a user id (the JWT
sub), or leave it empty to test as an anonymous visitor. Add an email and any extra claims your policies read through$auth.claims. - Optionally add a sample insert or update, with the row id for an update, as JSON.
- Click Run test.
The result lists the policies that apply to that user for each operation, the rows they can read (and how many of the table's rows that is), and whether the sample write would be allowed. For an update, both the check expression (new values) and the using expression (the existing row) must pass; if the row does not match using, the real update would affect no rows and the tester reports it as denied.
The tester runs the same enforcement code as live requests with an x-auth-token, on a query-only database connection, so its answer matches what your app would get. It tests the environment selected in the dashboard.
It is also available over the API for dashboard sessions and OAuth tokens with the settings permission:
POST /api/v1/projects/{projectId}/env/{env}/rls/test
Content-Type: application/json
{
"table": "posts",
"userId": "user_123",
"claims": { "orgId": "org_456" },
"limit": 100,
"sample": { "operation": "update", "id": "post_1", "data": { "title": "Edited" } }
}
The response includes rlsEnabled, authenticated, policies (policy names by operation), rows, visibleCount, totalCount, readError (set when reads are refused, for example a $owner policy with no user) and sample (allowed, reason and, for updates, rowTargetable).
WebSocket Subscriptions
RLS also applies to real-time subscriptions. When connecting via WebSocket with a token query parameter, the token is validated and used to filter subscription results per subscriber. Each subscriber only receives rows they're authorised to see.
wss://your-project.tthr.io/ws?token=eyJhbG...
When a mutation occurs, Tether re-executes each subscriber's query with their individual RLS context, ensuring filtered results are delivered correctly.
Custom Functions
By default, custom functions bypass RLS — they execute with full access, similar to a service role. This lets you implement complex business logic that may need to read or write across user boundaries.
If you need RLS within a function, pass the user's auth token through and use the CRUD API with the appropriate headers.
Troubleshooting
Rows are unexpectedly hidden
- Use Test policies (Settings → Security) with the user's id and claims to see which policies apply and which rows they can read
- Check that RLS is enabled on the table (Settings → Security)
- Verify at least one policy matches the operation you're performing
- Ensure your
x-auth-tokencontains the expected claims (check JWT contents) - Remember: with no matching policies + RLS enabled = all access denied
Writes are being rejected
- Run the write as a sample in Test policies to see the reason it is denied
- Check the
checkexpression on insert/update policies - For updates, both
using(target row) andcheck(new values) must pass - The
$ownershorthand requires the specified column to matchauth.sub
RLS isn't being applied
- RLS only applies when
x-auth-tokenis present — plain API key requests bypass RLS - Dashboard access always bypasses RLS (you're the project owner)
- Make sure RLS is toggled on for the table, not just that policies exist