"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

  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
  • 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

  1. Check that RLS is enabled on the table (Settings → Security)
  2. Verify at least one policy matches the operation you're performing
  3. Ensure your x-auth-token contains the expected claims (check JWT contents)
  4. Remember: with no matching policies + RLS enabled = all access denied

Writes are being rejected

  1. Check the check expression on insert/update policies
  2. For updates, both using (target row) and check (new values) must pass
  3. 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