Row-Level Security
Row-Level Security
Under Postgres row-level security (RLS), the same query with the same parameters returns different rows to different sessions. A cache that ignored the session would serve one tenant's rows to another.
Readyset reads your RLS policies from pg_catalog and, for a shallow cache over an RLS-protected table, folds the session state those policies actually read into the cache key. Each session gets its own entries, so a cache hit can only ever return rows that session was already entitled to see. Policies Readyset cannot analyze safely are refused, and the query is served from the upstream database instead.
This is on by default on a Postgres upstream and needs no per-cache configuration. It applies to shallow caches only; RLS-protected tables are not supported by deep caching.
RLS awareness partitions caches by the session state the policies read. It is not a general per-user authorization layer: a table with no RLS policy is cached with a shared, session-independent key, so any client authenticated to Readyset can read it regardless of its upstream GRANTs. See the caveat in Shallow Caching.
How it works
Cache partitioning
At CREATE CACHE time, Readyset resolves the relations the query references and checks whether any of them has RLS enabled.
- No RLS-protected table: the cache is plain. Its key is the query and its parameters, exactly as without RLS, and there is no added lookup cost.
- An RLS-protected table: the cache is scoped. Readyset analyzes the table's policy expressions and records the minimal set of session inputs they read. That set is folded into the key alongside the parameters, so the cache holds one independent entry per distinct combination of parameters and session values.
The inputs that can be folded into a scoped key are:
- The session's current role and session user
request.jwt.claimsclaims read viaauth.uid(),auth.jwt(), orauth.role()- Allowlisted GUCs read via
current_setting() - Whether the role holds
BYPASSRLS - The catalog generation (see Policy changes)
Only the inputs a policy actually reads are keyed. A policy reading auth.uid() partitions on the sub claim and nothing else; adding an unrelated GUC to the session does not fragment the cache.
Readyset tracks the session state it needs by observing SET, SET ROLE, set_config(), and DISCARD ALL / RESET ALL on the connection. Transaction-local writes (SET LOCAL ROLE, set_config(..., true)) revert to the session baseline at COMMIT or ROLLBACK. If Readyset cannot mirror the session's state confidently, it serves the query from upstream rather than guessing — see Off-cache routing.
Supported policies
Policy expressions must fit a deliberately narrow grammar, shaped around the Supabase and PostgREST idiom. A policy that steps outside it is not cached, rather than cached approximately.
Allowlisted functions:
| Function | Session input read |
|---|---|
auth.uid() | request.jwt.claims → sub |
auth.jwt() | request.jwt.claims (whole blob) |
auth.role() | request.jwt.claims → role |
current_user, session_user | the session's role identity |
current_setting(name[, missing_ok]) | name, if allowlisted below |
Allowlisted current_setting() GUCs: request.jwt.claims, request.jwt.claim.sub, request.jwt.claim.role, request.jwt.claim.email, request.method, request.path.
The allowlist is matched by name. A function is never accepted on the basis of its declared volatility, so a STABLE function of your own in a policy expression refuses the cache.
Views are analyzed against their underlying base tables when the view is declared security_invoker = true. A non-invoker view over an RLS-protected table is refused, because its rows are filtered as the view's owner rather than the caller.
Refused caches
When a policy or query falls outside the supported grammar, CREATE CACHE fails and the query keeps being served from the upstream database. Run EXPLAIN CACHE SUPPORT <query> to see the reason. Each reason carries a stable code in the form rls_uncacheable[code=<code>, ...], so tooling can switch on the code rather than parse the message:
| Code | Meaning |
|---|---|
policy_too_dynamic | The policy expression uses constructs outside the supported grammar. |
non_stable_function_in_policy | The policy calls a function that is not on the allowlist. |
policy_reads_other_table | The policy expression references a second table, so the rows it admits depend on that table's contents. |
view_without_security_invoker | The query reads a view over an RLS-protected table and the view is not security_invoker = true. |
security_definer_function | The query calls a SECURITY DEFINER function. |
partitioned_table | The RLS-protected table is partitioned. |
unknown_relation | Readyset has not observed one of the referenced relations yet. Retry after the next catalog poll. |
unknown_relation is the one transient entry in this table. It means the table exists but the catalog poller has not picked it up, which is expected for a few seconds after the table's CREATE. Retry the CREATE CACHE after the next poll; see --rls-poll-interval-secs.
Policy changes
Readyset polls pg_catalog for policy, RLS-enablement, and role-attribute changes every --rls-poll-interval-secs (60 seconds by default). This gives you two independent staleness bounds:
- Cached rows are fresh to within the cache's TTL.
- Cached policies are fresh to within the poll interval.
When a poll detects a change, Readyset reacts before serving anything under the new catalog state:
| Change | Effect |
|---|---|
| A policy's expression is edited | Affected caches are re-analyzed. Still in grammar: the cache is re-keyed on the new input set. Out of grammar: the cache is dropped and the query serves from upstream. |
ALTER TABLE ... ENABLE ROW LEVEL SECURITY | Plain caches over the table are dropped. They come back scoped through the normal create path if still cacheable. |
ALTER TABLE ... DISABLE ROW LEVEL SECURITY | Scoped caches over the table are invalidated. |
A role attribute changes (including BYPASSRLS) | Every scoped cache is invalidated. Any scoped cache is reachable by any role, so the affected set cannot be narrowed. |
Any of these bumps a global catalog generation that is part of every scoped cache key, which makes entries written under the previous policies unreachable immediately. They are reclaimed lazily by TTL and eviction.
Shortening the poll interval tightens the policy-staleness window at the cost of more catalog queries against your upstream. Lengthening it does the reverse.
Off-cache routing
Some conditions make a single lookup unsafe without making the cache itself invalid. Readyset then serves that lookup from upstream and caches nothing, while the cache stays in place for other sessions. This happens when:
- The session's state could not be mirrored confidently — for example a malformed
set_config()call left Readyset unsure of the live value. - A keyed GUC is unset in the session and Readyset cannot rule out a role-level default for it, because it lacks
SELECTonpg_db_role_setting.
Off-cache lookups increment readyset_shallow.shallow_result_uncacheable. This is distinct from a miss: nothing is stored, so it never warms. A session stuck in this state routes all of its traffic upstream while the overall hit rate can still look healthy, which makes this metric worth alerting on.
Scoped caches are also excluded from background refresh and refresh lazily in-session on a miss. Background refresh workers run on their own connections with no session state, so a background refill would execute under the wrong RLS context.
Required privileges
The connection in --upstream-db-url reads the catalog. It needs EXECUTE on pg_get_expr plus SELECT on:
| Relation | Required | Purpose |
|---|---|---|
pg_policy | Yes | Policy expressions |
pg_class | Yes | RLS-enabled flags, relation OIDs |
pg_namespace | Yes | Schema resolution |
pg_roles | Yes | Role attributes, including BYPASSRLS |
pg_rewrite, pg_depend | Yes | View-to-base-table expansion |
pg_db_role_setting | Recommended | Role-level GUC defaults |
If a required relation is unreadable, Readyset refuses to start, rather than come up and cache RLS-protected tables with a shared key. The error names the relations it could not read.
If only pg_db_role_setting is unreadable, Readyset starts and degrades: it cannot see role-level GUC defaults, so any lookup whose keyed GUC the session left unset is served off-cache. Sessions that set their GUCs explicitly are unaffected.
Monitoring
| Metric | Type | Meaning |
|---|---|---|
readyset.rls.poll_age_seconds | Gauge | Seconds since the last successful catalog poll. Alert when it exceeds your poll interval; the cache is serving under a stale view of the policies. |
readyset.rls.poll_initialised | Gauge | 1 once the first poll has succeeded. Gate poll_age_seconds alerts on this to avoid firing during startup. |
readyset.rls.poll_errors_total | Counter | Failed poll attempts. A rising count next to a rising poll_age_seconds means the registry has fallen behind. |
readyset.rls.policy_reloads_total | Counter | Reloads, labelled kind = relation | role | rls_flag. |
readyset.rls.bootstrap_attempts_total | Counter | Catalog bootstrap attempts at startup, including retries. |
readyset_shallow.shallow_result_uncacheable | Counter | Lookups served off-cache. |
Polling is fail-open: a failed poll leaves the previous catalog snapshot in place and retries on the next tick, so a transient upstream blip does not drop your caches. The tradeoff is that a sustained poll failure lets policies go stale without bound, which is what poll_age_seconds is for.
Configuration
| Option | Default | Purpose |
|---|---|---|
--rls-poll-interval-secs | 60 | How often the catalog is polled for policy changes. |
--enable-rls | true | Set to false to skip the catalog entirely. Unsafe unless no table has, or ever gains, RLS. |
Limitations
- Policy expressions must fit the grammar described in Supported policies. Anything else is served from upstream.
- Partitioned tables with RLS are not cached.
- RLS-protected tables are not supported by deep caching.
- Scoped caches do not participate in background refresh; they refresh in-session on a miss.
- A policy change takes effect within one poll interval, not instantly. Postgres event triggers are not used, because they are unavailable on hosted Supabase without superuser.