API keys & RLS policies¶
| Method | Route | Description |
|---|---|---|
| GET | /projects/{id}/keys |
List a project's API keys |
| POST | /projects/{id}/keys |
Create a key ({ name, permissions: [...], expires_at? }) |
| DELETE | /projects/{id}/keys/{key_id} |
Revoke a key |
| GET | /projects/{id}/tables/{table_id}/rls-policies |
List a table's RLS policies |
| POST | /projects/{id}/tables/{table_id}/rls-policies |
Create a policy ({ name, operation, expression, enabled? }) |
| DELETE | /projects/{id}/tables/{table_id}/rls-policies/{policy_id} |
Delete a policy |
permissions is a subset of select, insert, update, delete. operation is one of SELECT,
INSERT, UPDATE, DELETE, ALL.
expression: real SQL, not a condition DSL¶
expression is a raw SQL boolean expression evaluated against the table's own columns —
OR, IN (...), comparisons between columns, whatever your database's WHERE clause
accepts.
$auth.id / $auth.email / $auth.role¶
These three are not columns of your table — they're a fixed set of placeholders the
API substitutes for the identity of the caller, i.e. the end user authenticated via
X-User-Token (a row in project_end_users, registered through POST
/{project_id}/auth/register, separate from your own platform login). Concretely:
$auth.id→ that end user's numeric id.$auth.email→ their email.$auth.role→ the free-textroleyou optionally assign them viaPATCH /projects/{id}/end-users/{end_user_id}(nulluntil you set one).
They're substituted as bound parameters, never string-concatenated. For an anonymous
caller (no X-User-Token sent, or an invalid/expired one) all three resolve to NULL —
and since column = NULL is never true in SQL, an ownership-style policy like created_by
= $auth.id excludes anonymous callers with no extra logic needed.
$auth.role is entirely optional — if your app has no notion of roles, just never
reference it. A policy like created_by = $auth.id (no role check at all) is perfectly
normal and is exactly what "each end user only sees/edits what they created" looks like.
Roles only matter once you want different end users to have different levels of access
(e.g. "a manager can only touch their own rows, an admin can touch anyone's").
{ "name": "owner or admin", "operation": "ALL", "expression": "created_by = $auth.id OR $auth.role = 'admin'" }
Multiple enabled policies registered for the same operation (including an ALL
policy, which applies to all four) are combined with OR — the same permissive-policy
semantics Postgres RLS uses. A table with no policies at all for an operation is fully
open for that operation (the pre-RLS default); a policy that never matches a given
caller/row simply filters it out, rather than returning an error.
- On
SELECT/UPDATE/DELETE, the expression scopes which rows are visible/touchable (Postgres calls thisUSING) — merged into the query'sWHERE. - On
INSERT, and again onUPDATE, the expression is also aWITH CHECK: after the write runs (with the client's submitted values, through the column whitelist), it's re-evaluated against the resulting row. A violation rolls the write back and responds403— a client that submitscreated_byother than their own id is rejected outright, not silently corrected to the right value. Design your policy expressions with this in mind: a client must supply values that already satisfy the policy, the same way a real PostgresWITH CHECKconstraint works.
Since policies are written by the platform owner via this management API — the same
person who already has unrestricted raw SQL access through the SQL Editor — expression
is trusted the same way: not parsed or whitelisted beyond a few cheap guardrails (must be
non-empty, under 10,000 characters, no ;/--//*, and only the three documented
$auth.* placeholders), which exist to catch mistakes with a friendly 422, not to
sandbox untrusted input.