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.
Top comments (0)