adding to that, since it's the half that usually gets left out: the folder path policy has to apply on insert too, not only select. the client chooses the path it uploads to, so if you only constrain reads then anyone can write into someone else's folder and you've built a one way leak into it. same expression both times, (storage.foldername(name))\[1\] = auth.uid()::text, one policy for select and one for insert.
one more for the boundary list: the exposed schemas setting is project wide, so every app's publishable key can reach every schema's endpoints. rls still has to say no, but each app now presents all ten schemas as surface instead of its own. with separate projects a policy bug in app c simply wasn't reachable from app a's client. now it is, and that one doesn't show up in the bill or in the migration work.
you don't have to give up rls. supabase takes third party auth providers now, better auth's jwt plugin exposes a jwks endpoint, you register that in the dashboard and the token gets verified like a native one. the detail that bites: auth.uid() casts the sub claim to uuid, and better auth ids aren't uuids by default, so it quietly returns null and every policy denies with no error anywhere. either use auth.jwt()->>'sub' with text user\_id columns, or make better auth generate uuids. worth deciding before you've written thirty policies.
one thing about that log, 422 on signup is usually "user already registered", so that particular request didn't create anything. worth rerunning with a fresh address before concluding what the endpoint does. and with email confirmation off, the risk that survives isn't junk rows, it's squatting. someone curls signups for real addresses they don't own, and when the actual person shows up the email is already taken. if you ever link a google login to an existing unconfirmed email, the attacker's password still works on the account they now share. that one keeps biting long after the bot traffic stops.
if the problem with turnstile is latency, move it off the critical path. fetch the token when the signup screen mounts instead of when they hit the button, and refresh it in the background. by the time someone's typed an email and a password you've had 20+ seconds, so the tap feels instant even on a bad network. also worth splitting the two costs. junk rows you mostly don't care about since phone verification makes them useless anyway. supabase emailing addresses that aren't yours is the one that actually hurts, that's your sending reputation. the auth rate limits in the dashboard knock that down without touching the app at all.
before you build on it, the thing to verify is whether [project-ref.supabase.co](http://project-ref.supabase.co) stays live after you add a custom domain. i'm fairly sure it does, and if so cloudflare in front of the vanity domain blocks nothing, curl just keeps using the old url with the same publishable key. worth confirming with support because your whole plan rests on that one detail. what definitely changes is oauth. the callback moves to your domain, so the redirect uri in the google console and the apple service id both need updating or sign in breaks, which is roughly what bit you last time.
one warning for when you actually write the policies: don't let the memberships table's own policy query memberships. postgres throws infinite recursion and it's a baffling error the first time you see it. the fix is a security definer function that returns the caller's org ids, and every policy calls that. also denormalise org\_id onto every table even where you could join to get there. policies run per row, and a three table join inside one is how a fast app becomes a slow app at 10k rows.
there's a middle option that doesn't come up much: you can keep the service role connection and still make postgres do the checking. open a transaction, set local role authenticated, set\_config [request.jwt.claims](http://request.jwt.claims) with the verified id, then run the query. it executes under rls even though you connected as admin. good for the jobs that genuinely can't carry a user session but should still be pinned to one user, and when you get it wrong the failure is a denial instead of someone else's rows.
if what you actually want is "only my app can call this", captcha is the wrong tool, attestation is the right one. app attest on ios and play integrity on android hand you a token the client can't forge, you verify it in an edge function and that function does the signup with the service role. the public endpoint stops being the signup path. it's real work to set up, but it's the only thing that answers "is this my binary" instead of "is this a human", and it costs the user zero seconds, unlike turnstile.
the investigate gap matches what i see, but i don't think it's reasoning, it's that agents default to reading the schema instead of querying it. a policy that looks right reads as right forever. what fixed it for me was forcing the check to produce evidence. open a transaction, set local role authenticated, set [request.jwt.claims](http://request.jwt.claims) to user a, run the select, repeat as user b, roll it back. either the row comes out or it doesn't, and no amount of confident policy reading argues with that.
the one i'd throw at it isn't a policy shape exactly, it's when the policy is correct and still isn't the boundary. table has a SECURITY DEFINER rpc as the intended write path with all the validation in it, and meanwhile authenticated still holds GRANT INSERT plus a permissive owner-only insert policy. every per table per command assertion passes, because inserting your own row genuinely is allowed. the client just never has to go through the rpc. not sure it's in scope since nothing about the policy is wrong, but that's how the rules get skipped in practice.
the column level part is what most tools skip, good call. one policy shape worth covering if you don't already: two permissive policies on the same command. they get OR'd, so a second policy missing its WITH CHECK quietly cancels the strict one sitting next to it, and each of them reads fine on its own. that's the failure i run into most and you only see it if you evaluate them together.
self hosting doesn't get you out of it, it just moves the whole control set onto you. audit logs, encryption at rest, access reviews, backups, breach process. that's cheaper than 900/mo only if your time is worth nothing. what i'd do first is shrink what actually counts as phi. if the clinical data lives in one narrow service and supabase only ever holds ids and scheduling, the compliance surface gets small enough that the hosting question mostly answers itself.
perfect, so a revoke takes effect on the next poll without the client having to do anything. that's the answer i was hoping for, thanks
good to know, that makes it an actual boundary and not just a bandwidth win. does that hold for a subscription that's already open when the grant gets revoked, or does the channel keep going until it resubscribes?
column selection is the good one, that used to be the reason people kept a skinny mirror table next to the real one. does select interact with column level grants at all or is it purely a payload thing? if a column is revoked for that role i'd want realtime to refuse it rather than let the client just ask for it.
scans miss this one: rls filters rows, not columns. the policy correctly limits you to your own row and then \`select \*\` hands back every column in it, stripe ids, internal flags, whatever. policy reads as perfect the whole time. no policy fixes that, you need revoke select on the table from authenticated and then grant select on just the columns you want. role\_column\_grants shows where you actually stand. ai generated schemas never do this part.
that instinct is right, don't ship something you can't explain. and you probably don't need it yet anyway. the registry sounds way bigger than it is though. it's a module-level `const channels = {}` keyed by room id, and before calling `supabase.channel()` you check if one is already there and reuse it. that's the whole thing. it's the same idea as your untrack fix, stop the duplicate from existing instead of cleaning it up after. untrack before unsubscribe is correct regardless, keep that one.
your theory's right — it's StrictMode. React 18 dev mounts→unmounts→remounts, so the effect subscribes+tracks, cleans up, then subscribes+tracks again. presence is keyed internally by `presence_ref` (one per subscription), not just your `key`, so when the untrack/removeChannel from the phantom mount hasn't fully propagated to the Realtime server before the second `.track()` lands, the server briefly holds two refs under your user\_id → the two entries you're seeing. so to your three questions: * **prod?** that specific trigger is dev-only (no StrictMode double-invoke in prod), so it won't fire there. but don't lean on that as the fix — the same race reproduces any time you rapidly re-subscribe: `auth.user_id`/`roomId` changing, reconnects, fast remounts. your dep array `[isLoading, roomId, auth.user_id]` re-runs the whole subscribe, so it's latent outside StrictMode too. * **module-level channel registry**: yes, that's a legit fix and it resolves StrictMode cleanly because the phantom mount/unmount refcounts against the same channel instance instead of creating a second one. you intuited the right pattern. * **simpler first step** before reaching for the registry: add a `subscribedRef` guard so the effect only subscribes once, and in your sync handler stop taking `data[0]` blindly — when there are two refs with different `ready` values you're picking one arbitrarily, which is why toggling "stops working". take the most-recent ref (or collapse by user\_id keeping the latest) and the UI stabilizes even if a dup slips through. (ignore the "just use zustand" reply, that's orthogonal — your state lib isn't the bug, the subscription lifecycle is.)
Maxyull's got the mechanism: the inactivity timer watches *Postgres* activity, not API-gateway traffic as a whole. so the question is what your 500-700 actually hit — storage image fetches and auth calls show up in your api logs but never touch the database, so they don't reset the timer. from your other comment it sounds like a chunk of yours are storage image queries, which would explain it. your actual table queries via postgrest *should* count though, so if genuine `select` traffic is still getting you paused, that's worth the support ticket tomlimon linked — 500-700 real DB queries a day pausing is not expected behavior. pragmatic keep-alive in the meantime: an external cron (GitHub Actions or [cron-job.org](http://cron-job.org), free) that fires one trivial `select 1` through postgrest every few hours guarantees a real DB hit and resets the timer. it's the standard hobby-tier warm-keeper until you either move to Pro or support sorts the ticket.
nice, that's the whole thing. one last practical note so it stays complete: the invariant is only as good as your enumeration of the "app deny" cases, so derive them from the same permission concepts your RBAC already uses (moderator scope, `character.edit.any`, etc.) rather than a hand-kept list. that way a new permission automatically gets an invariant check instead of being the one someone forgets to add. good luck with it, this was a genuinely good AMA 🙌
"RLS is the hard boundary, app-level is fail-early UX" is exactly the right model, and honestly the fact that you can state it that cleanly is more than most projects have. the agreement suite is the right follow-up, but it's smaller than "exhaustive differential" once you use the asymmetry. because RLS is the enforcement, only one of the two mismatch quadrants is a security bug: \-app says *allow*, RLS says *deny* → user hits a broken feature. annoying, pure UX, not dangerous. \-app says *deny*, RLS says *allow* → the leak. your UI hides the action, so nothing prompts anyone to look, but a raw PostgREST call with that user's JWT does it anyway. the app-level check was giving false assurance. so the suite doesn't need full agreement, it needs one invariant: **everything the app forbids, RLS must also forbid** (RLS is at least as strict as the app claims). you already have the app-RBAC decision as a pure-ish function and the seeded fixtures — for each user/row/op, assert `if appRBAC == deny then rlsOutcome == deny`. you can ignore the allow/allow and the harmless allow/deny quadrant entirely. that collapses "exhaustive matrix" into "assert the DB is never more permissive than the app pretends," which is generatable from the fixtures you've got. that also plugs straight into your dump→staging step: run the invariant post-restore and drift where a migration loosened RLS but not the app layer fails loud instead of waiting to be noticed.
the bit i'd go deep on: you said RLS and app-level RBAC are layered together rather than either alone. how do you keep the two from drifting? my worry with that setup is the day RLS says a user can see a row but the app-level RBAC says they can't (or vice versa), and now "who's the source of truth" is ambiguous and a bug in one silently widens the other. do you test that the two layers *agree* — like a suite that asserts the RBAC decision and the RLS outcome match for the same user/row — or is RLS the hard boundary and RBAC purely UX on top of it? and has the daily dump→staging restore ever caught a case where a migration changed one layer but not the other? that drift is the exact thing i'd expect to bite at 200 migrations and i'm curious how you fenced it.
one correction that makes this safer: you never need to paste a key into [jwt.io](http://jwt.io) or any site to tell them apart. a JWT's payload is just base64 — decode it locally (or `console.log(atob(token.split('.')[1]))`) and read the `role` claim offline. agreeing with the "don't send keys to a website" crowd here, that includes jwt.io. and the real fix for the whole "they look identical" problem is migrating to the new API key format (`sb_publishable_...` / `sb_secret_...`). the prefix tells you which is which at a glance, no decoding, and you can't fat-finger the secret one into your frontend without it being obvious. tomlimon linked the migration guide above — that basically retires this footgun.
>
anytime, good luck with it
fair question but it's the opposite of what it sounds like. the point isn't that *i* want to change someone's row — it's proving a malicious user *can't*. it's an authorization test, and the passing result is that the write gets rejected. threat model: any logged-in user can skip your app entirely and fire a raw request at the API with their own valid JWT — `PATCH /rest/v1/table?id=eq.<someone-elses-row-id>`. your nice UI doesn't matter, PostgREST is a public endpoint. if RLS + grants are tight, that request fails. if they're loose, they just edited another user's data from a terminal. the test simulates exactly that hostile request and asserts it fails. and no, not in prod. the in-db version runs inside a transaction that rolls back, so it mutates nothing — you're checking the database *refuses* the write, then throwing the whole thing away. run it in CI against a test db. it's the same idea as a pen-test: you try the attack in a controlled way so you find the hole before someone else does.
ah nice, didn't realize it was open — that changes things. the authenticated-role pass is the highest-value thing to add imo, since "is it public to anon" and "does it leak between logged-in users" are really two different tests and the second is the one that bites multi-tenant apps. might poke at it when i get a sec. even if i don't get to a PR, the shape is simple: same requests you already fire, but with a real user's JWT in the Authorization header instead of the anon key, then assert the rows come back scoped to that user. drop it in as a second mode and it roughly doubles what the skill can catch.
this is the logical product of your Lovable-repo thread — testing the result instead of reading the policy files is exactly the right framing, nice. two things worth building in if they aren't already: test as an *authenticated* user too, not just anon. the scary case in multi-tenant apps isn't "anon reads everything", it's "logged-in tenant A reads tenant B's rows" — RLS is on, anon is blocked, and it still leaks between real users. you'd hit `/rest/v1/table` with a real user's JWT and assert you only get your own rows. and the one thing black-box-from-outside genuinely can't reach: the write side + column UPDATE grants. you can't safely test "can user B mutate a column they shouldn't" without actually mutating someone's row. that half needs an in-db two-user test (impersonate + rolled-back transaction). so your skill covers the read surface from outside, and the write/column surface wants the sql test — together they're the full picture. are you planning to add an authenticated-role pass, or keeping it anon-only for the "is it public at all" check?
nice. one thing that makes it stick as a check rather than a "remember to look": you can query it instead of eyeballing. `information_schema.role_column_grants` filtered to `grantee = 'authenticated'` and `privilege_type = 'UPDATE'` lists every column the role can write, per table. drop it in a test and assert your sensitive columns (tenant\_id, role, is\_admin) aren't in the result. then a broad grant that sneaks back in via a future migration fails CI instead of waiting for a review to catch it. same trick with `SELECT` for the read-side column leaks. turns the checklist item into a regression test, which is the only version that survives contact with a team shipping fast.
here's the uncomfortable part: broad grants aren't the unusual case, they're the default. it's not weird schemas at all. supabase sets things up so the API roles get wide table grants out of the box (effectively `grant all on all tables in schema public to anon, authenticated`, plus default privileges so new tables inherit it). the whole design is "grants are open, RLS is the gate." so every table you create starts life with full column-level select/update for authenticated unless you go back and scope it. column-scoping is the extra step you have to *add*, and no tutorial mentions it, so basically nobody does. that's why it's everywhere the dev didn't deliberately lock it down, which is most apps. and you're dead right about detecting it from outside — `select=sensitive_col` either returns or 42501s, so the read side is black-box testable. the write side is the one you can't safely probe from outside without actually mutating someone's row, which is exactly why i push the in-db two-user test for it: `update ... set role='admin'` under tenant B's identity should fail, and you can assert that inside the rolled-back transaction without touching real data. so to your actual question: regularly, and specifically because it's the default rather than a mistake someone had to make. the app works, the policy looks right, and the grant nobody scoped is sitting wide open underneath.
solid list, and the "check the layers around the policy" framing is the right one. the OR-combining gotcha pgsql-dev1 raised is the one that bites hardest, i've seen a single loose permissive policy quietly widen a table that had three tight ones. the one that saved me and isn't in the list: column-level grants on UPDATE. everyone reviews grants for SELECT, but `grant update` defaults to all columns, and WITH CHECK on ownership doesn't restrict *which* columns change. so a user who's allowed to update their own row can flip a column they were never meant to touch (role, tenant\_id, is\_admin) and every policy passes because the row is theirs. the fix is `revoke update` then `grant update (col_a, col_b)` on just the user-editable columns. same story for SELECT and sensitive columns, and the tell is `select('*')` suddenly erroring once you lock it down. your two-user isolation test is the thing that actually proves any of this though. the write-with-B's-identifiers-must-fail assertion is the one people skip and it's the one that catches the column leak too.
was in your RLS thread the other day, glad you actually went and got numbers. and the humbling-afternoon bit about your own scanner flagging public keys as critical is exactly why the post is trustworthy, ignore the guy calling it bs. to answer your closing question directly: yeah, i check from the outside, but the private-window-and-curl thing doesn't scale past a couple tables and you'll stop doing it by deploy three. what does scale is making it a test instead of a chore — a sql script that impersonates two users in one transaction (`set local role authenticated` \+ set the jwt claims), asserts tenant A sees zero of tenant B's rows and B's writes fail, then rolls back. runs in CI on every migration, no live app needed. that's the version you'll actually keep running. and the one thing even a correct policy misses, because it's the layer past what you scanned: RLS gates rows, not columns. a perfect policy still leaks a sensitive column if the column grant is broad, and on updates a user can mutate a column your WITH CHECK never guards. so "RLS on + tight policy" isn't the finish line, the grants are part of the result too. that's the gap that doesn't show up in a repo scan OR the advisor.
the staging-table-plus-async answers here are right, but one thing nobody flagged: you can't RPC your way out of the auth.users part. creating a user properly (password hash, identities row, metadata) has to go through the GoTrow Admin API, a plain SQL insert into auth.users will bite you later. so "just move it to an RPC" only applies to the public.users/profile/progress inserts, not the account creation. the shape that actually holds up: \-client uploads the list into an `imports` table, gets back a job id immediately. done, no timeout. \-an edge function processes it with `EdgeRuntime.waitUntil()` so it keeps running after returning 202. work in chunks of \~10-20, and make each row idempotent (status column: pending/done/failed) so a retry never double-creates. \-client subscribes to the status table over realtime to show progress + which rows failed. the gotcha that'll actually get you: 100 students + 100 parents = 200 Admin API createUser calls. if email confirmations are on that's 200 emails and you'll hit GoTrue's rate limit fast. pass `email_confirm: true` on admin-created users so it skips the confirmation send. that alone might be why it's crawling.
not overthinking it at all, this is the real gap. "RLS enabled" and "RLS correct" are two different things and the app looks fine in both. the way i actually verify it: a sql test that impersonates users, no app involved. inside one transaction you do `set local role authenticated` \+ `set_config('request.jwt.claims', ...)` with a fake `sub` for user A, run your queries, then assert three things — A sees their own rows, A sees zero of B's rows, and A *can't* update/delete B's rows (should error or affect 0 rows). switch the sub to B, repeat. end with a rollback so it never touches data. that whole thing runs in CI and it's the only way i trust a policy is tight and not just on. two that have actually bitten me: \-RLS gates rows, not columns. a policy can be perfect and someone still reads a sensitive column because the column grant is open. same for updates — WITH CHECK on ownership doesn't stop them mutating a column they shouldn't. you have to REVOKE/GRANT at the column level too. \-the service\_role key thing you mentioned is the scariest because it bypasses RLS entirely. if it's ever in the frontend bundle, every policy you wrote is decorative. grep your build output for it before shipping. the AI-fix-by-reaching-for-service\_role pattern is exactly why i don't trust "it works now". the negative test (user B gets blocked) is the one that catches it.
The cli doc covers it but two things people miss and then spend an hour debugging: \-restore the `auth` schema too, not just `public`. if you only bring back your app tables, everyone's logins break because auth.users is empty. and storage objects are separate again. \-enable the same extensions on the new project *before* you restore (pgcrypto, uuid-ossp, whatever you used) or pg\_restore throws on the first function that needs them. connection string is in Settings → Database, use the direct/session one for the restore not the pooler. did your backup come from `supabase db dump` or a full pg\_dump? changes which flags you want.
if downgrading fixes it that's worth a quick issue on the supabase-js repo with the two versions — sounds like a regression they'd want to know about. either way i'd keep the retry-once in place even after it works. cloud clock skew is transient by nature so it can come back randomly and a single retry makes it a non-issue instead of a support ticket. lmk if the older version does the trick
ah ok, if the device clock's fine then it's probably skew on supabase's side - the token gets minted by the auth server and validated by postgrest/postgres, and if those two are a hair out of sync a fresh token can look "future" for a few seconds. usually transient. couple things that help: - retry the request once on that specific error, it normally passes on the 2nd try - make sure supabase-js is up to date, there were session/refresh fixes - if self-hosting, sync the container clocks (ntp) is it cloud or self-hosted? and does a retry fix it, or does it stay broken until you reopen the app?
the one that got me wasn't compute, it was egress from serving images straight out of storage with no caching. threw a cdn/cache in front + cache-control headers and it dropped a lot. also set a spend cap and check the usage breakdown early, before it surprises you.
that's clock skew almost every time the device clock is a bit ahead of the server so the token's iat looks like it's in the future. set the phone/emulator to automatic network time (emulators drift a ton). if it only happens to some users it's their clock, not your app. it clears on the next token refresh once the clock is right, so forcing a getSession refresh when the app resumes usually hides it too.
Depends on the use case for me. branching is handy but the per-branch cost adds up, so i only use it for short-lived testing stuff. for anything that sticks around (staging, a second project) i just keep everything in sql migrations and apply them to a fresh project - that's my source of truth, no dumps. cloning becomes "create project, run migrations, seed", reproducible and free. pg_dump --clean works for a one-off full copy too, just watch the auth schema/roles when you restore.