"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 (Deno runtime) | No | Developer-written server code |
| Direct SQL (Hrana) | No | Raw SQL cannot be reliably filtered |
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- 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 |
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" }
]
}
}
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 (running in the Deno runtime) 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
- 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
- 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