I audited 78 Supabase Edge Functions with "Verify JWT" turned off. Here's the checklist I used
I'm building Kordhub solo: a platform where creators sell paid Discord/Telegram access, live stream gifts and custom requests, paid straight to their own Stripe account. The whole backend is Supabase: Postgres plus 78 Ed
I'm building Kordhub solo: a platform where creators sell paid Discord/Telegram access, live stream gifts and custom requests, paid straight to their own Stripe account. The whole backend is Supabase: Postgres plus 78 Edge Functions.
Almost all of them run with Verify JWT turned off. That sounds scary, but it's often the right call: webhooks from Stripe, Discord and Telegram don't send a Supabase JWT, cron jobs don't either, and public pages (checkout, overlays, buyer confirmation links) are used by people who never log in.
The catch: once the gateway stops checking, every function has to authenticate the caller itself. So this weekend I downloaded all 78 and checked each one. Here's the method, in case it helps you do the same.
- Back up and download everything bash npx supabase link --project-ref npx supabase functions download --use-api # all functions, no Docker needed
Tip: --use-api unbundles server-side, so you don't need Docker just to get the source.
- Classify every function by "who is allowed to call this?"
I grepped each index.ts for the auth pattern it uses and put every function in exactly one bucket:
Bucket How it authenticates Count
Logged-in user auth.getUser() with the caller's JWT, then checks they own the resource 31
Internal only Authorization must equal the service role key 6
Cron only Bearer ${CRON_SECRET} — and rejects if the secret is missing 12
Webhooks Stripe constructEventAsync, Discord Ed25519, Telegram secret header 3
Public + credential Unguessable token / signed payload / Stripe session id + per-IP rate limit 20
Public, harmless No data, no side effects 1
If a function doesn't fit any bucket, that's your finding.
Two details that are easy to get wrong:
ts
// ❌ If CRON_SECRET is unset, this compares against "Bearer undefined"
if (auth !== Bearer ${Deno.env.get('CRON_SECRET')}) return unauthorized();
// ✅ Fail closed when the secret is missing
const CRON_SECRET = Deno.env.get('CRON_SECRET');
if (!CRON_SECRET || auth !== Bearer ${CRON_SECRET}) return unauthorized();
And for logged-in functions, getUser() only tells you who is calling. You still have to check that the row they're touching belongs to them.
- What I actually found
The main app was fine. The problems were in 5 functions from an older side project living in the same Supabase project (a license system for a browser extension):
a) A 6-digit recovery code with no attempt limit. 900,000 possible codes, valid for 10 minutes, unlimited guesses = brute-forceable with a script. Fix: max 5 attempts per code (then it's deleted), per-IP rate limit, and generate it with crypto.getRandomValues instead of Math.random().
b) ilike with raw user input. This one surprised me:
ts
// ❌ "%" is a wildcard: sending email = "%" matched ANY license
.ilike('email', email.trim())
// ✅ escape % _ and \ so the match is literal (still case-insensitive)
const escapeLike = (v: string) => v.replace(/[\%_]/g, (c) => '\' + c);
.ilike('email', escapeLike(email.trim()))
Combined with (a), someone could have hijacked a license without even knowing the victim's email.
c) An email-sending endpoint with no rate limit. Anyone could make it send hundreds of emails to any address, burning the email quota and the sending domain's reputation. Fix: limit per IP and per recipient.
- The rate limiter
All of this relies on one tiny Postgres function shared by every Edge Function (a counter per key and time window), called like this:
ts
const { data: ok, error } = await admin.rpc('check_rate_limit', {
p_key: recover:${ip}, p_limit: 10, p_window_seconds: 600,
});
if (error) return json({ error: 'rate_limit_check_failed' }, 503); // fail closed
if (ok === false) return json({ error: 'rate_limited' }, 429);
I fail closed almost everywhere. The one exception is a read-only "is this license active?" check, where blocking on a database hiccup would downgrade paying users.
Checklist
Every function is in exactly one auth bucket
Secrets are checked with !SECRET || so a missing env var fails closed
Logged-in functions verify ownership, not just identity
Every public endpoint with a credential has a per-IP rate limit
Short codes have an attempt limit and use crypto.getRandomValues
No raw user input in ilike / like filters
Endpoints that send email are rate-limited per recipient too
It took one afternoon and found a real account-takeover path. If you run Supabase Edge Functions with Verify JWT off, I'd recommend doing the same.
I'm building Kordhub in public: on-stream gifts, paid Discord & Telegram access and custom requests for creators, paid straight to their own Stripe. If you have questions about the setup, ask in the comments.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.