"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

  1. Go to your project Settings → Security
  2. Toggle RLS on for each table you want to protect
  3. 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
  • using applies to reads (select) and the target rows of updates/deletes
  • check applies to inserts and the new values in updates
  • A policy with no check holds new data to its using expression instead, as in Postgres, so a using: { $owner: 'userId' } policy also stops a user writing a row with someone else's userId. 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:

  1. Pick a table.
  2. 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.
  3. Optionally add a sample insert or update, with the row id for an update, as JSON.
  4. 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

  1. Use Test policies (Settings → Security) with the user's id and claims to see which policies apply and which rows they can read
  2. Check that RLS is enabled on the table (Settings → Security)
  3. Verify at least one policy matches the operation you're performing
  4. Ensure your x-auth-token contains the expected claims (check JWT contents)
  5. Remember: with no matching policies + RLS enabled = all access denied

Writes are being rejected

  1. Run the write as a sample in Test policies to see the reason it is denied
  2. Check the check expression on insert/update policies
  3. For updates, both using (target row) and check (new values) must pass
  4. The $owner shorthand requires the specified column to match auth.sub

RLS isn't being applied

  1. RLS only applies when x-auth-token is present — plain API key requests bypass RLS
  2. Dashboard access always bypasses RLS (you're the project owner)
  3. Make sure RLS is toggled on for the table, not just that policies exist