惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

H
Help Net Security
爱范儿
爱范儿
V
Visual Studio Blog
Last Week in AI
Last Week in AI
阮一峰的网络日志
阮一峰的网络日志
雷峰网
雷峰网
Hugging Face - Blog
Hugging Face - Blog
博客园 - 【当耐特】
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
博客园 - 三生石上(FineUI控件)
博客园 - Franky
博客园 - 叶小钗
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
大猫的无限游戏
大猫的无限游戏
人人都是产品经理
人人都是产品经理
M
MIT News - Artificial intelligence
罗磊的独立博客
L
LangChain Blog
Jina AI
Jina AI
IT之家
IT之家
J
Java Code Geeks
Microsoft Azure Blog
Microsoft Azure Blog
博客园_首页

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
What I learned shipping a 5-day auction marketplace in 30...
Jason · 2026-04-29 · via DEV Community

What I learned shipping a 5-day auction marketplace in 30 days (Cloudflare Pages + Supabase)

I built an auction marketplace for online businesses called ExitBid — single-region MVP from idea to live in about 30 days. This is a notes-from-the-field write-up of the technical decisions that worked, the ones that didn't, and the patterns I'd use again. Stack is dead-simple: Cloudflare Pages for the static site, Supabase for everything backend, Resend for transactional email. Paid integrations: Creem.io as Merchant of Record, NowPayments for crypto, Vonage for SMS verification.

I'm not going to pretend this is novel. The point of writing it down is the combination of choices and the gotchas that surface when you actually ship.

The architecture in one paragraph

The whole site is static HTML+CSS+JS hosted on Cloudflare Pages, served from the edge globally. The "backend" is entirely Supabase — Postgres for state, Auth for sessions, Storage for assets, Realtime for live bids, RLS for authorization, Edge Functions for the few things that need server-side logic (email OTP send, payment webhooks, crypto invoice creation). No Node server. No container. No K8s. The whole infra runs at $25/mo total — Supabase Pro plan plus Cloudflare Pages free tier.

The bid system

The interesting part. Auctions are 5-day timed runs with a hard close — no soft-close overtime, no extensions in the last minutes (sellers can extend the whole auction up to 3 times for $50 each, but only well before close). 14 concurrent slots on the homepage bento grid, $500 minimum bid increment.

The shape that mattered most: keep the display of current_bid cheap. Every visitor to the site sees the live auction grid and wants up-to-date numbers. Originally I was computing this client-side from the bids table for each card on render. Doesn't scale. Switched to a denormalized current_bid column on the auctions row, kept in sync via an AFTER INSERT trigger on bids:

CREATE OR REPLACE FUNCTION bids_after_insert()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
  UPDATE auctions
  SET current_bid = GREATEST(COALESCE(current_bid, 0), NEW.amount),
      bid_count   = COALESCE(bid_count, 0) + 1,
      last_bid_at = GREATEST(COALESCE(last_bid_at, '-infinity'::timestamptz), NEW.created_at)
  WHERE id = NEW.auction_id;
  RETURN NEW;
END $$;

Enter fullscreen mode Exit fullscreen mode

GREATEST matters more than it looks. I had a bug where back-dated test bids would pull last_bid_at backwards in time. Wrapping it in GREATEST makes the trigger monotonic — the timestamp only ever moves forward, regardless of insert order.

RLS as a foundation, not an afterthought

The single biggest leverage in this stack is row-level security on Postgres. Every table has policies. The auth-context comes from the JWT Supabase issues. Critically, this means the frontend can talk directly to Postgres via PostgREST — no API layer in the middle. A bid insert is literally supabase.from('bids').insert({...}). The policy checks the auth.uid() against the bidder_id and validates the bidder is verified. No backend code involved.

Where RLS doesn't fit, I use SECURITY DEFINER RPCs. Verifying an OTP code, atomic counters, anything that needs to bypass narrow column grants — these become functions with explicit grants to authenticated. Example for bidder OTP:

CREATE OR REPLACE FUNCTION verify_bidder_otp(p_code TEXT)
RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public AS $$
DECLARE v_uid UUID := auth.uid(); v_row RECORD;
BEGIN
  IF v_uid IS NULL THEN RAISE EXCEPTION 'Not authenticated'; END IF;
  SELECT * INTO v_row FROM bidder_otp_codes
    WHERE user_id = v_uid AND used = false AND expires_at > now()
    ORDER BY created_at DESC LIMIT 1;
  IF NOT FOUND THEN RAISE EXCEPTION 'No active code'; END IF;
  -- ... attempts check, code comparison ...
  UPDATE profiles SET phone_verified = true WHERE id = v_uid;
  RETURN true;
END $$;
GRANT EXECUTE ON FUNCTION verify_bidder_otp(TEXT) TO authenticated;

Enter fullscreen mode Exit fullscreen mode

SECURITY DEFINER runs with the function-owner's permissions (postgres role), so it can write to profiles even though the caller's RLS policies wouldn't allow direct UPDATE. The SELECT/UPDATE are strictly scoped to WHERE id = auth.uid() so a malicious caller can only affect their own profile. This pattern handled 90% of "I need server-side logic" needs without ever opening a Node process.

Realtime: the trap is in the publication

Supabase Realtime broadcasts INSERT/UPDATE/DELETE events from any table in a publication. By default, every public table is in supabase_realtime. This is fine for a small site, but each table in the publication forces logical decoding to walk the WAL for that table on every commit. With 8+ tables in the publication, my disk IO spiked enough that Supabase emailed me a warning.

The fix was 3 lines:

ALTER PUBLICATION supabase_realtime DROP TABLE notifications;
ALTER PUBLICATION supabase_realtime DROP TABLE sponsored_ads;
ALTER PUBLICATION supabase_realtime DROP TABLE support_messages;

Enter fullscreen mode Exit fullscreen mode

Audit which tables your frontend actually subscribes to, and drop the rest. In my case the UI subscribes to bids, messages, and questions — three tables, not eight.

The verification cost trap

I shipped with phone+email "verification" originally as a self-attestation: user types a phone number, RPC stores it, profile gets phone_verified=true. No SMS sent. This was fine as a friction-gate for spam but obviously not real verification.

Adding real SMS turned out to be the most painful integration. Twilio, MessageBird, Vonage, TextBelt, Brevo, Plivo — every provider has a different signup flow, different minimum top-up, different sender-ID policies, different country coverage. Most card-rejected my Eastern European Visa. The one that finally worked: Vonage on a free €2 trial credit, then refilling from a different card through their "buy credits" page (different processor than signup).

Lesson for next time: don't roll SMS verification yourself. Use Supabase's built-in auth.signInWithOtp({ phone }) + Twilio Verify config, set up at the project level. The integration is trivial; the painful part is just acquiring an SMS provider account, and that's a one-time cost.

What I'd build differently

Things that worked and I'd do again:

  • Cloudflare Pages + Supabase + Resend trio. $25/mo total. Edge-served HTML with dynamic data over a single Postgres connection.
  • Trigger-based denormalized counters (current_bid, bid_count, last_bid_at) instead of computing on read.
  • SECURITY DEFINER RPCs for every "this needs server-side logic" moment. Beats spinning up a backend.
  • pg_cron for periodic jobs (auction expiry, OTP cleanup) instead of external cron runners.

Things I'd skip:

  • mDNS / Bonjour for local agent discovery — Windows multicast is unreliable and the warnings filled my logs for weeks before I disabled it.
  • Self-rolled cron through external runners — every external scheduler I tried (OpenClaw cron, GitHub Actions, Cloudflare Cron Triggers) had subtle failure modes. pg_cron inside Postgres is the simplest reliable option when your job is a SQL call.
  • Heavy bidder-fee tracking. I started with deposits ($100 refundable), then switched to phone+email-only verification. The deposit added 4 layers of code (escrow, refund flow, ledger reconciliation) and zero buyer behavior change vs simple verification.

The brand-name lesson

The non-technical thing that surprised me: search engines have hard time disambiguating new brands. "ExitBid" looks visually similar to EZBID (US industrial-equipment auction), abetter.bid, and a couple of unrelated Instagram handles. For the first six weeks, Google in the CIS region ranked an unrelated Instagram account higher than my actual site for the literal query "exitbid". I wrote about that in a separate post — short version: when you're picking a brand, search the visual variations of the name, not just the exact spelling.

Final stack tally

Hosting:          Cloudflare Pages (free tier)
Database / Auth:  Supabase Pro ($25/mo)
Email:            Resend (free tier, 100 emails/day)
Payments:         Creem.io (Merchant of Record), NowPayments (crypto)
SMS verification: Vonage (€2 trial, then pay-as-you-go)
DNS / CDN:        Cloudflare
Languages:        English + Russian (hreflang annotated sitemap)

Enter fullscreen mode Exit fullscreen mode

Live at exitbid.io if you want to see what it looks like. Auctions are running, bidding is free after one-time verification.

Alex Web, founder of ExitBid