The REVOKE that didn't
Platform defaults are part of your security posture. Verify the outcome you want — not the action you took.
- In code review I caught a SECURITY DEFINER function — a database routine that runs with its owner's privileges, not the caller's — exposed to every logged-in user, letting them skip ~200 lines of app validation.
- I revoked it. But Supabase grants EXECUTE to the anonymous `anon` role by default, and `REVOKE … FROM PUBLIC` doesn't touch that grant.
- For about three days the “locked-down” operation was callable by anyone via `POST /rest/v1/rpc/record_verification`, no account required — strictly worse than the original hole.
- The fix that held: `REVOKE` from `public`, `anon`, and `authenticated`, with a test that asserts all three. Verify the state, not the action.
The hole I caught
ReadySetBind has one step that matters more than the rest: taking an insurance placement a human underwriter just verified and moving it forward to signing — the pending_verification → signing transition, plus the correction records that ride along with it. The app does this through record_verification, a Postgres SECURITY DEFINER function — a database routine that runs with its owner's privileges instead of the caller's, so it can write tables the caller isn't allowed to touch directly. The route that calls it runs about two hundred lines of validation first.
During code review I caught the problem: record_verification was granted to authenticated, the role every logged-in user gets. Supabase exposes functions like that over its REST API, so any agency member could call it directly — supabase.rpc('record_verification', …), a remote procedure call that invokes the database function straight over the API — and skip all two hundred lines, forcing whatever state transition they wanted. The validation wasn't a gate. It was a suggestion you could decline.
Good catch, and an early one — caught before merge, no real placements touched. I hardened it: revoked the function from regular users, restricted it to the service role (the backend's own identity, not a person's), added an explicit p_verified_by argument the route has to vouch for, and a membership re-check inside the function itself.
The fix that wasn't
The hardening migration did this:
REVOKE EXECUTE … FROM PUBLIC, authenticated
It looks right. It is also wrong, and here is the one line that's the whole story: Supabase default-grants EXECUTE — the privilege to run a function — to anon, the anonymous role used for requests with no login at all, on every new function. And REVOKE … FROM PUBLIC does not remove a grant made directly to anon. PUBLIC and anon are not the same set of roles, however much the names suggest otherwise.
So I revoked the two roles I was thinking about and left the one I wasn't. For about three days, the freshly "locked-down" verify primitive was callable by anyone on the internet through POST /rest/v1/rpc/record_verification, no account required — strictly worse than the hole I'd just closed. A logged-in user who could skip validation had become no user at all.
My verification query is what waved it through. It asked exactly one question: has_function_privilege('authenticated', …) — can logged-in users still call this? The answer was no, the check went green, and I moved on. I never asked the same question about anon.
What actually worked
REVOKE EXECUTE … FROM PUBLIC, authenticated;REVOKE EXECUTE … FROM anon;the whole bugPUBLIC and anon aren't the same set of roles. Supabase default-grants EXECUTE to anon on every new function, so the first revoke left the verify primitive reachable by unauthenticated REST calls for ~3 days. The correction revokes anon too — and the test now asserts the privilege is gone for all three roles.
The durable fix was boring and explicit. The correction migration revokes EXECUTE from all three roles — public, anon, authenticated — and the test now asserts the privilege is gone for each one, not just the role I happened to be thinking about.
Then I turned it into a standing rule: every SECURITY DEFINER function ships with a deliberate, written-down grant scope, and a test checks it. It's the function-level version of a rule the project already lived by for tables — every table gets row-level security (the database filters rows per user), no exceptions. Functions needed the same line drawn around them.
Verify the state, not the action
The lesson isn't "memorize the Supabase default." Defaults change; the next platform will have a different one waiting to bite. The lesson is that I verified my action — "I revoked access" — instead of the state I actually cared about — "nobody unauthorized can call this." Those are different claims, and the gap between them is exactly where this class of bug lives.
In an assessment I'd file this as a configuration-baseline finding: the platform did something on my behalf that my threat model never accounted for. As a builder the rule is shorter. When a platform does something for you automatically, assume it also did something you didn't ask for — and test for the outcome you want, not the step you took.