Here is the function that keeps me up at night. It is a Supabase Edge Function called checkout, maybe 60 lines of Deno, and it does what every checkout does: takes an order id, looks up the cart, confirms the price, writes a row. To read the cart across users it was given the service_role key, because RLS was getting in the way during a Friday afternoon and the fastest unblock was to step outside RLS entirely. That decision shipped. The function is now public at https://<project>.supabase.co/functions/v1/checkout, and the key inside it can read and write every row in every table, ignoring every policy you wrote.
Now the part people miss. Your app has a WAF. Cloudflare, or the Vercel firewall, or whatever sits in front of your Next.js origin. That WAF is doing real work on app.yourcompany.com. It is doing zero work on the checkout function, because the checkout function does not run behind it. It runs on Supabase's edge, at a different hostname, on infrastructure your CDN never sees. An attacker who hits the function URL directly walks straight past the thing you think is protecting you.
The edge is the blind spot on purpose
The whole selling point of edge and serverless functions is that they run close to the request and independent of your origin. Supabase Edge Functions, Vercel Edge, Cloudflare Workers: same shape. They spin up in a lightweight isolate, handle the request, and disappear. That independence is exactly why the WAF in front of your main app does not cover them. Different host, different path, different network. The firewall config you spent a day tuning applies to routes that terminate at your origin, not to a Deno isolate answering on functions.supabase.co.
And these functions are not the low-value part of your stack. They are where you put the logic too sensitive for the browser: signing URLs, charging cards, minting tokens, reading other users' data with elevated privileges. Supabase Edge Functions are public by default. There is no implicit login gate. Anybody who knows the URL can send a request, and the function itself is the only thing deciding whether that request is legitimate. It is the trust boundary, and most of the time there is nothing sitting on it except your own if statements.
I have watched teams add a WAF, feel covered, and never notice that the three functions holding the service_role key have no protection at all. The monolith got the guard. The functions that can drop a table got nothing.
A signature WAF would not save you here anyway
Say you did route the function through a classic WAF. It still would not catch the attacks that actually hit these things. A signature WAF (the negative-security model) works off a list of known-bad patterns. SQL injection strings, XSS payloads, the log4shell probe, a directory of CVE fingerprints. It is a bouncer with a list of banned faces.
The attacks against a checkout function do not have banned faces. GET /checkout/1001 when you are user 1002 is a textbook IDOR/BOLA, and every byte of it looks like a normal request, because it is one. It is a valid method, a valid path, valid syntax. There is no payload to match. Broken authentication looks like a request missing a header. Business-logic abuse (replaying a discount, ordering a negative quantity, calling steps out of order) is made entirely of legal requests in an illegal sequence. A pattern matcher has nothing to match on. The OWASP API Security Top 10 has been led by BOLA and broken auth for years precisely because signature tools are blind to them.
The model that works is the inverse. Positive security. Instead of listing what is bad, you learn what is normal for this specific function and block everything else. The function serves GET /products unauthenticated and POST /checkout authenticated with an integer order id. That is the allow-list. GET /checkout/1001 from an unauthenticated caller is not on it, so it does not get through, and nobody had to predict that particular attack in advance. The unknown request fails because it deviates from the baseline, not because someone wrote a rule.
The fix is one line around your handler
This is Nemesis Shield. The edge SDK is a single function, withShield, that wraps your Deno handler and puts the allow-list in front of it. Here is the checkout function before:
Deno.serve(async (req) => {
const { orderId } = await req.json();
const cart = await db.from("carts").select().eq("id", orderId).single();
return new Response(JSON.stringify(cart), {
headers: { "content-type": "application/json" },
});
});
And after:
import { withShield } from "jsr:@nemesis-shield/edge";
Deno.serve(withShield(async (req) => {
const { orderId } = await req.json();
const cart = await db.from("carts").select().eq("id", orderId).single();
return new Response(JSON.stringify(cart), {
headers: { "content-type": "application/json" },
});
}, { token: Deno.env.get("NEMESIS_TOKEN") }));
The import is the JSR package jsr:@nemesis-shield/edge (on npm runtimes like Cloudflare Workers or Vercel Edge it is @nemesis-shield-autogon/edge, same withShield signature). The token is read from an environment variable, never hardcoded. You set it once with the Supabase CLI:
supabase secrets set NEMESIS_TOKEN=nsk_your_app_token
Grab a free token at shield.nemesislabs.xyz. That is the whole integration. withShield(handler, { token }) returns a (Request) => Response handler, which is exactly what Deno.serve wants, so it drops in with no other changes.
What actually happens after you deploy
The thing I want to be clear about, because it is what makes this safe to ship on a Friday: it does not start blocking. On first deploy the function is in observe mode. Every request runs your handler exactly as before, and the SDK records the shape of the request (method, normalized route, the structure of query params, whether the caller was authenticated) so it can learn what normal looks like. Nothing is blocked. You cannot break production by adding it, because in observe mode there is no enforcement to break.
Learning from live traffic takes as long as it takes to see all your routes, which for a quiet function could be a while. So there is an agent that does it in minutes. Nemesis Learn exercises every route of your app in dev or staging and lets the baseline complete fast:
npx @nemesis-shield-autogon/learn --target http://localhost:54321 --app-token nsk_... --repo .
It sends representative, benign traffic (never attack payloads), can read your repo to discover routes from source, and posts a coverage report back so the console shows a baseline-readiness meter. When it looks complete, you open the app in the console, approve the learned behaviors, and flip the mode to enforce. No redeploy. The SDK refreshes its compiled policy lazily on the request path with a short TTL, so the next request on a cold isolate picks up the new mode on its own.
One property I lean on hard: it is fail-open. Look at the actual handler in the SDK and the entire policy-refresh-and-decide block is wrapped so that any internal error falls straight through to your code. If Shield is unreachable, if the policy fetch times out, if anything in the middleware throws, your function still runs. The only path that returns a 403 is an explicit, deliberate block in enforce mode. A security tool that can take down your checkout when its own backend has a bad day is worse than no tool. This one cannot.
The honest gotchas
It learns from the traffic you give it, so give it clean traffic. If you run Nemesis Learn against a staging environment that is already full of junk, or you leave observe mode running while someone is actively fuzzing the function, that noise becomes part of the baseline and you will approve behaviors you did not mean to. Exercise the real routes, review what it learned before you approve, and do not enforce on a baseline you did not look at.
It models structure, not values. The shape it records is method plus normalized route plus param names and kinds (is this an integer, a uuid, an email) plus an auth flag plus status. It never ships request bodies, and it never ships your users' data. That is a deliberate privacy choice, and it is also a limit: an attack that stays entirely inside your normal request shapes (a valid authenticated request that abuses logic the function itself permits) is not something a behavioral allow-list catches. That class needs business-logic scoring, which is a different tool. Do not let anyone sell you an allow-list as if it catches everything. It catches deviation from learned normal, which is a large and painful category, and not the whole of it.
And the free tier has limits on volume and app count. For a couple of functions it is genuinely free. If you are running a fleet, you will hit the paid tiers eventually. I would rather tell you that now than have you find out at request one million.
Add it from your editor
If you build with an AI editor (Cursor, Claude Code, Windsurf, anything that speaks MCP), you can have the agent wire this in for you. There is a local MCP server:
npx -y @nemesis-shield-autogon/mcp
Point your editor at it and "add Nemesis Shield to my checkout function" becomes a thing your agent can actually do, in observe mode, reading the token from the environment, mounted first so it sees every route. The free tools need no account.
The checkout function that reads every row with the service_role key is not going away. It is how you build on Supabase. Put something on the boundary it lives on.
David Obi
Top comments (0)