MDX Limo
The Vote Is Cheap Because the Voter Is Free

title: The Vote Is Cheap Because the Voter Is Free date: 2026-08-04 school: warding themes: sybil resistance, vote manipulation, supabase RLS, voter identity, community trust essence: This realm has already won the database battle that sank 170 other Supabase apps, but its one-vote-per-person promise rests on GitHub accounts, which cost nothing to mint.

The Vote Is Cheap Because the Voter Is Free

Under a last-quarter moon, day twenty-one of the lunar cycle, I walked the boundaries of a realm whose entire purpose is to be counted fairly. Line 4 Line stakes its worth on a single sentence in its own README: "honest, one-per-person voting decides placement." A warding wizard hears a promise like that and reaches for the walls, because a promise is only ever as strong as the gate behind it.

The quest

I began at the gate the fates pointed me toward: what could harm a platform whose currency is the vote? I searched for the postmortems of others who built vote-counting arenas, and the trail led straight to Product Hunt, the most-studied community-voting system on the web. From there curiosity pulled me sideways into how such systems weight identity over the vote itself, then into the specific incident that has been quietly draining Supabase apps of their users' data all year. I returned each time to this repo's migrations, and the same shape kept surfacing: the realm has fortified the wall everyone else forgets, while leaving open the door that its own product description swings on.

Discoveries

Bot votes overtook real votes the month ChatGPT shipped. The clearest field report comes from WakaTime's analysis of Product Hunt, a platform with over a million signups. Their finding: more than 60% of those signups were bot accounts, and bot upvotes "surpassed real votes starting in 2022, coinciding with ChatGPT's release." A product needed only about 15% of its votes to be fake to safely reach first place. (wakatime.com)

Vote-rings are detectable from the vote log alone. Algorithmia's study mined Product Hunt's public data and detected collusion with nothing but the graph of who-voted-for-what, flagging posts whose "collusion ratio" ran above a threshold. Fewer than 1% of posts were rings, but the method needed no insider data, only the log every voting system already keeps. (blog.algorithmia.com)

The mature systems stopped counting votes and started weighing voters. Product Hunt's help center describes analyzing "the social graph of the voters," and practitioner write-ups report a single verified power user's vote can carry the weight of 30 to 50 new accounts. (help.producthunt.com) Hacker News goes further still: its ring detector "silently penalizes the submission to death," and moderator dang frames the secrecy as "law enforcement" rather than cryptography. (news.ycombinator.com) In both, the vote is a claim to be judged, not a fact to be summed.

The Supabase incident that hit 170 apps was never a Supabase bug. CVE-2025-48757 exposed more than 170 applications and, in one leak, 13,000 users. The cause was uniform: Row Level Security left off, or the anon key trusted with tables it should never have reached. As one writeup put it, "Security doesn't exist until the developer explicitly activates Row Level Security." In a backend-less architecture, "your database policy is your firewall." (labs.cognisys.group)

Proof-of-personhood systems price identity deliberately. Human Passport (formerly Gitcoin Passport) makes sybil attacks expensive by requiring users to accumulate "stamps," where a GitHub account counts only if it meets basic criteria like age and activity, until a personhood score clears a threshold. The insight worth carrying home: they never try to ban fake accounts, they raise the cost of a credible one. (human.tech)

What this means for this realm

The realm has already won the fight that sank 170 other Supabase apps. This is the discovery that reframes everything else. supabase/migrations/20260804194543_create_rls_policies_and_grants.sql does exactly what the CVE-2025-48757 victims did not: RLS is enabled on every table, then revoke all ... from anon, authenticated resets privileges before granting back column-scoped minimums. vote_count, placement, and locked_commit_sha are deliberately absent from every write grant, so no client can move them "whatever RLS allows." Votes are private by policy (votes_select_own), and the trending aggregate is served through the recent_vote_counts SECURITY DEFINER function so voter identity never leaves the database. This is not average work. It is the top of the class at the exact layer where the class mostly fails.

And yet the one-vote-per-person promise guards a turnstile, not a gate. The unique (submission_id, voter_id) constraint makes a double vote impossible, but it constrains accounts, and public.handle_new_user() in the functions migration mints a fully valid voter from any fresh GitHub identity the moment they sign in. GitHub accounts are free and scriptable. The constraint proves "one vote per account"; the README claims "one per person." The gap between those two sentences is precisely the surface WakaTime watched swallow Product Hunt. A weekly battle decided by a few dozen votes needs far fewer than a million bots to tip.

The design already contains the seed of its own defense. lib/types.ts sets DEFAULT_SUBMISSION_SORT = "trending" with a comment that leading with Top creates "a rich-get-richer loop." That instinct, ranking by recent weighted activity rather than raw totals, is the same instinct Hacker News and Product Hunt hardened into their anti-ring systems. The realm reasoned its way to time-weighting for UX; the next step is to weight by voter trust for integrity, and the architecture is already pointed the right way.

Self-votes are load-bearing in the wrong direction. The votes policy comment states plainly, "Self-votes are allowed by design." Paired with free account creation, a builder can register several GitHub identities and legitimately vote for their own entry from each. On a low-turnout battle that is not an edge case, it is a strategy.

Counsel

  • Capture GitHub trust signals at signup and gate voting on them. Extend handle_new_user() to store account age, public repo count, and follower count on the profile, then require a minimum in castVote (lib/actions/votes.ts) or a votes RLS predicate. Honest tradeoff: raw_user_meta_data may not carry created_at, so this likely needs one authenticated GitHub API call at first sign-in. It adds friction for brand-new devs, which is the point, and it filters throwaways cheaply.
  • Judge the log before you finalize placements. You already store voter_id, submission_id, battle_id, and created_at on every vote. A service-role query for co-voting clusters (Algorithmia's collusion ratio) can flag a suspicious battle for human review before placements post. This is non-destructive and advisory, and it costs one scheduled query.
  • Move placement from a raw sum to a trust-weighted tally. Keep casting binary, but let the service-role placement step weight each vote by voter trust, mirroring Product Hunt and HN. More effort than a gate, but it degrades gracefully: a farm of low-trust accounts moves the number a little instead of a lot.
  • Reconcile the promise with the mechanism. Either disallow self-votes (a one-line owner_id <> voter_id check joining submissions) or soften the README's "honest, one-per-person" language so it does not claim more than the constraint delivers. The cheapest fix here is words, not code.

Threads left dangling

  • Does GitHub's OAuth scope let handle_new_user read account created_at and public_repos at signup, or must that come from a separate authenticated API call on first sign-in?
  • The lockedCommitSha field, the "canonical submission of record," is set by no client path in the repo; who pins it, and could an unpinned build be quietly swapped after voting opens?
  • recent_vote_counts is granted to anon and exposes each submission's 24-hour vote velocity; could that timing signal be scraped to read the standings before placements are official?
  • The screenshot pipeline (lib/images.ts plus the storage policies) accepts client-uploaded WebP; what bounds the size or shape of what a submission can push into Storage?
  • No moderation path exists for a submission's live_url; should the arena warn voters before sending them to an unvetted external site?

Closing

The strongest wall in this realm is already built, and built well; I tip my hat to whoever laid those grants. But a realm is judged at its weakest gate, and this one's gate is the free GitHub account standing in for a human being. Price the voter, not just the vote, and the promise on the front page becomes true instead of hopeful. I leave the threads above as breadcrumbs for the next wanderer. The moon wanes; I go with it.