<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: David | Kordhub</title>
    <description>The latest articles on DEV Community by David | Kordhub (@kordhubdev).</description>
    <link>https://dev.to/kordhubdev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4011223%2Fd598ace2-0557-4066-a41d-263687f23ab9.png</url>
      <title>DEV Community: David | Kordhub</title>
      <link>https://dev.to/kordhubdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kordhubdev"/>
    <language>en</language>
    <item>
      <title>I audited 78 Supabase Edge Functions with "Verify JWT" turned off. Here's the checklist I used</title>
      <dc:creator>David | Kordhub</dc:creator>
      <pubDate>Sun, 25 Oct 2026 22:26:58 +0000</pubDate>
      <link>https://dev.to/kordhubdev/i-audited-78-supabase-edge-functions-with-verify-jwt-turned-off-heres-the-checklist-i-used-55bg</link>
      <guid>https://dev.to/kordhubdev/i-audited-78-supabase-edge-functions-with-verify-jwt-turned-off-heres-the-checklist-i-used-55bg</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Back up and download everything
bash
npx supabase link --project-ref 
npx supabase functions download --use-api   # all functions, no Docker needed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tip: --use-api unbundles server-side, so you don't need Docker just to get the source.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Classify every function by "who is allowed to call this?"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I grepped each index.ts for the auth pattern it uses and put every function in exactly one bucket:&lt;/p&gt;

&lt;p&gt;Bucket  How it authenticates    Count&lt;br&gt;
Logged-in user  auth.getUser() with the caller's JWT, then checks they own the resource 31&lt;br&gt;
Internal only   Authorization must equal the service role key   6&lt;br&gt;
Cron only   Bearer ${CRON_SECRET} — and rejects if the secret is missing  12&lt;br&gt;
Webhooks    Stripe constructEventAsync, Discord Ed25519, Telegram secret header 3&lt;br&gt;
Public + credential Unguessable token / signed payload / Stripe session id + per-IP rate limit  20&lt;br&gt;
Public, harmless    No data, no side effects    1&lt;/p&gt;

&lt;p&gt;If a function doesn't fit any bucket, that's your finding.&lt;/p&gt;

&lt;p&gt;Two details that are easy to get wrong:&lt;/p&gt;

&lt;p&gt;ts&lt;br&gt;
// ❌ If CRON_SECRET is unset, this compares against "Bearer undefined"&lt;br&gt;
if (auth !== &lt;code&gt;Bearer ${Deno.env.get('CRON_SECRET')}&lt;/code&gt;) return unauthorized();&lt;/p&gt;

&lt;p&gt;// ✅ Fail closed when the secret is missing&lt;br&gt;
const CRON_SECRET = Deno.env.get('CRON_SECRET');&lt;br&gt;
if (!CRON_SECRET || auth !== &lt;code&gt;Bearer ${CRON_SECRET}&lt;/code&gt;) return unauthorized();&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What I actually found&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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):&lt;/p&gt;

&lt;p&gt;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().&lt;/p&gt;

&lt;p&gt;b) ilike with raw user input. This one surprised me:&lt;/p&gt;

&lt;p&gt;ts&lt;br&gt;
// ❌ "%" is a wildcard: sending email = "%" matched ANY license&lt;br&gt;
.ilike('email', email.trim())&lt;/p&gt;

&lt;p&gt;// ✅ escape % _ and \ so the match is literal (still case-insensitive)&lt;br&gt;
const escapeLike = (v: string) =&amp;gt; v.replace(/[\%_]/g, (c) =&amp;gt; '\' + c);&lt;br&gt;
.ilike('email', escapeLike(email.trim()))&lt;/p&gt;

&lt;p&gt;Combined with (a), someone could have hijacked a license without even knowing the victim's email.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The rate limiter&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All of this relies on one tiny Postgres function shared by every Edge Function (a counter per key and time window), called like this:&lt;/p&gt;

&lt;p&gt;ts&lt;br&gt;
const { data: ok, error } = await admin.rpc('check_rate_limit', {&lt;br&gt;
  p_key: &lt;code&gt;recover:${ip}&lt;/code&gt;, p_limit: 10, p_window_seconds: 600,&lt;br&gt;
});&lt;br&gt;
if (error) return json({ error: 'rate_limit_check_failed' }, 503); // fail closed&lt;br&gt;
if (ok === false) return json({ error: 'rate_limited' }, 429);&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Checklist&lt;br&gt;
 Every function is in exactly one auth bucket&lt;br&gt;
 Secrets are checked with !SECRET || so a missing env var fails closed&lt;br&gt;
 Logged-in functions verify ownership, not just identity&lt;br&gt;
 Every public endpoint with a credential has a per-IP rate limit&lt;br&gt;
 Short codes have an attempt limit and use crypto.getRandomValues&lt;br&gt;
 No raw user input in ilike / like filters&lt;br&gt;
 Endpoints that send email are rate-limited per recipient too&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I'm building Kordhub in public: on-stream gifts, paid Discord &amp;amp; Telegram access and custom requests for creators, paid straight to their own Stripe. If you have questions about the setup, ask in the comments.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>5 things that broke while building paid Discord &amp; Telegram access with Stripe and Supabase</title>
      <dc:creator>David | Kordhub</dc:creator>
      <pubDate>Sun, 18 Oct 2026 20:42:42 +0000</pubDate>
      <link>https://dev.to/kordhubdev/5-things-that-broke-while-building-paid-discord-telegram-access-with-stripe-and-supabase-505i</link>
      <guid>https://dev.to/kordhubdev/5-things-that-broke-while-building-paid-discord-telegram-access-with-stripe-and-supabase-505i</guid>
      <description>&lt;p&gt;I'm building &lt;a href="https://kordhub.com" rel="noopener noreferrer"&gt;Kordhub&lt;/a&gt;, a tool that lets creators sell access to their Discord server or Telegram group. Someone pays with Stripe, the bot gives them a role. They cancel, the role is removed.&lt;/p&gt;

&lt;p&gt;Sounds simple. Here are 5 things that broke while testing it with real payments.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Stripe sends the same cancellation twice
&lt;/h2&gt;

&lt;p&gt;When a member cancels from the Stripe customer portal, Stripe fires two &lt;code&gt;customer.subscription.updated&lt;/code&gt; events almost at the same time (the second one carries the cancellation survey answer). Both read the old state before either wrote, so I logged the cancellation twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; make the update conditional ("only if it's not already canceling") and ask for the updated rows back. If nothing comes back, another event already handled it, so skip the log and the notification.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The bot can't assign a role above its own
&lt;/h2&gt;

&lt;p&gt;Discord returns &lt;code&gt;403 (code 50013)&lt;/code&gt; if the bot's role is below the role you're trying to give. New servers hit this all the time, because the bot's role lands at the bottom when it joins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; catch that exact code and tell the creator in plain words: "drag the Kordhub role above the role you're selling, then tap Re-send access". Turning a cryptic error into one instruction saved a support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Telegram won't remove the group owner
&lt;/h2&gt;

&lt;p&gt;When a subscription expired, my Telegram ban call failed with &lt;code&gt;Bad Request: can't remove chat owner&lt;/code&gt;. I was testing with my own account, which owns the group.&lt;/p&gt;

&lt;p&gt;Lesson: owners and admins can't be removed by a bot. Treat that error as "nothing to do", not as something to retry forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Don't DM people directly from your webhook
&lt;/h2&gt;

&lt;p&gt;Discord rate limits are global per bot. Sending DMs straight from the payment webhook works in testing and falls over with real traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; every DM goes into a queue (a Postgres table + a small worker), so a burst of sales never fights the rate limit and failures are logged per job.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Expiration needs its own clock
&lt;/h2&gt;

&lt;p&gt;Stripe tells you when a subscription is deleted, but not every access type has a Stripe subscription (one-time passes, manual grants). A scheduled job (pg_cron every 5 minutes) checks &lt;code&gt;expires_at&lt;/code&gt;, removes access, and cancels anything still open in Stripe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;Supabase (Postgres, Auth, Edge Functions, pg_cron), Stripe Connect with direct charges, Cloudflare Pages, vanilla JS.&lt;/p&gt;

&lt;p&gt;If you're building something similar, I hope this saves you a few hours. And if you run a paid community, you can try it free at &lt;a href="https://kordhub.com" rel="noopener noreferrer"&gt;kordhub.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>supabase</category>
      <category>discord</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built Kordhub App: sell Discord &amp; Telegram access without managing roles by hand</title>
      <dc:creator>David | Kordhub</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:59:18 +0000</pubDate>
      <link>https://dev.to/kordhubdev/i-built-kordhub-app-sell-discord-telegram-access-without-managing-roles-by-hand-3fkl</link>
      <guid>https://dev.to/kordhubdev/i-built-kordhub-app-sell-discord-telegram-access-without-managing-roles-by-hand-3fkl</guid>
      <description>&lt;p&gt;Hi, I'm David, founder of &lt;a href="https://kordhub.com" rel="noopener noreferrer"&gt;Kordhub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run a paid Discord or Telegram community, you know the routine: someone pays, you add the role by hand, someone cancels, you forget to remove it. I built Kordhub to automate that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Kordhub does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sell Discord access&lt;/strong&gt;: buyers pay, the bot gives them the role automatically and removes it when the subscription ends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sell Telegram access&lt;/strong&gt;: same flow for private Telegram groups and channels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream gifts&lt;/strong&gt;: viewers can send gifts during your live streams.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free link-in-bio page&lt;/strong&gt;: one link with everything you sell.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Payments go through Stripe Connect, straight to the creator's own Stripe account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;Existing tools like Whop and LaunchPass work, but I wanted something simpler for creators who just need paid roles, free trials and clean payouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Frontend: HTML, Tailwind, vanilla JS on Cloudflare Pages&lt;/li&gt;
&lt;li&gt;Backend: Supabase (Postgres, Auth, Edge Functions)&lt;/li&gt;
&lt;li&gt;Payments: Stripe Connect&lt;/li&gt;
&lt;li&gt;Discord bot + Telegram bot&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;It's free to start: &lt;a href="https://kordhub.com" rel="noopener noreferrer"&gt;kordhub.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'm also posting short tutorials as &lt;strong&gt;@kordhubapp&lt;/strong&gt; on &lt;a href="https://x.com/kordhubapp" rel="noopener noreferrer"&gt;X&lt;/a&gt;, &lt;a href="https://instagram.com/kordhubapp" rel="noopener noreferrer"&gt;Instagram&lt;/a&gt;, &lt;a href="https://tiktok.com/@kordhubapp" rel="noopener noreferrer"&gt;TikTok&lt;/a&gt; and &lt;a href="https://youtube.com/@kordhubapp" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Feedback welcome, especially if you run a paid community. What would make you switch?&lt;/p&gt;

</description>
      <category>discord</category>
      <category>saas</category>
      <category>buildinpublic</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
