Episode 2/4 of the mini-series The week Supabase lied to me 4 times. Episode 1 — 3 Supabase security incidents, one shared root cause: SECURITY DEFINER inherits EXECUTE TO PUBLIC — covered SaaS defaults that lie by misplaced trust. Episode 3 [CANONICAL URL EPISODE 3: to complete after push] will look at RLS recursion. Episode 4 [CANONICAL URL EPISODE 4: to complete after push] will explain the silent 1000-row cap.
Wednesday of the week Supabase was lying
Monday was episode 1 — a SECURITY DEFINER function callable by anon because Postgres had silently added EXECUTE TO PUBLIC that nobody wrote. Tuesday I was drafting episode 3 on the RLS recursion that returns zero without saying why. Wednesday morning, Pauline walked in with a spreadsheet and a quiet smile.
She put her screen down in front of me — no noise, no preamble. Twenty rows, column A name, column B status, column C supposed archival date. "I found them all this morning, still active in the CRM." She said it with the politeness of someone who already knows it's a code problem, not a spreadsheet problem. Then she waited.
Twenty contacts archived the evening before. Green toast, "Archiving successful", team message sent to confirm. Pauline's spreadsheet found them all the next morning, still active, still visible in the CRM. No error in the logs. No Sentry alert. Not a single red line anywhere. Twenty silent rows saying simply no, the operation never happened — with nobody listening.
This is episode two of a week when Supabase lied to me four times. Episode one said SaaS defaults lie through misplaced trust. Episode two says something different and the same thing: an API return value lies when the caller refuses to open the envelope.
The contract nobody talks about
@supabase/supabase-js made a defensible choice long ago: don't throw exceptions on DB errors. A rejected mutation — from a CHECK constraint, an RLS policy, a trigger that says no — doesn't crash the caller. It returns cleanly with a { data, error } object where data is null and error carries the message. Return-value-error convention, borrowed from Go, defensible in isolation.
Formidable consequence. A caller that doesn't destructure error doesn't have the error. Not hidden by a badly-written try / catch, not swallowed by a distracted .then(). Simply: nobody claimed it, so nobody saw it. The contract holds when both parties honor it; it collapses the moment one party looks away.
The doctrine I maintain in ~/doctrine-counterpart/CLAUDE.md calls this R10, Silent failure forbidden. The most dangerous lie in a system isn't the one that shouts a falsehood, it's the one that stays quiet about a failure. Silence is a readable state — you just need a device that reads it.
The code that lied
I open the archiving module. Twenty lines on the server action side, and this one at the center:
// app/crm/actions.ts — contact archiving (before fix)
async function archiveContact(id: string) {
const supabase = createSupabaseServer()
const now = new Date().toISOString()
await supabase
.from('contacts')
.update({ statut: 'ancien', archived_at: now })
.eq('id', id)
// no destructure, no check, no throw
return { ok: true }
}
The return { ok: true } was written two lines below, like a declaration of confidence nothing supported. And the RLS policy, written a few weeks earlier, didn't include UPDATE on contacts for Pauline's role. The Postgres server did its job: it rejected the mutation, returned the error, and moved on to the next query. Nobody read the envelope. The green toast appeared.
This isn't a bug. It's a contract the caller didn't honor. And an unmet contract is rigorously indistinguishable from a met one when nobody's looking — that's precisely what makes it a structural lie, not a one-off defect.
Why ESLint isn't enough
I wrote an ESLint rule the week before, no-bare-await-on-supabase-mutation, that cleanly catches the orphaned await supabase.from(...).delete().eq(...) at end of statement. The rule eliminated 56 occurrences across the repo. But it lets through a more insidious variant:
// passes the linter, still lies
const { data } = await supabase
.from('contacts')
.update({ statut: 'ancien', archived_at: now })
.eq('id', id)
if (!data) toast.error('Error') // data null if row missing OR if error is set
The destructure is there — data is read — but error isn't. The !data test conflates two distinct cases: the row didn't exist and the policy refused. One is business logic, the other is security, and both surface here as a single generic "error". For Pauline, error is a hollow word that says neither what to fix nor who to ask for permission.
A linter fixes what grammar lets through. But when grammar is legal and it's usage that lies, the linter misses it. You need a device higher in the stack.
The wrapper that enforces destructuring
The fix I put in place is a single layer. A typed mutate() helper that makes error non-optional in its return signature, forcing TypeScript to refuse compilation if the caller ignores the field.
// lib/supabase-mutate.ts
import type { PostgrestBuilder } from '@supabase/postgrest-js'
import type { PostgrestError } from '@supabase/supabase-js'
import * as Sentry from '@sentry/nextjs'
export async function mutate<T>(
query: PostgrestBuilder<T>
): Promise<{ data: T | null; error: PostgrestError | null }> {
const result = await query
if (result.error) {
Sentry.addBreadcrumb({
category: 'supabase.mutation',
message: result.error.message,
data: { code: result.error.code, details: result.error.details },
level: 'error',
})
}
return result
}
// Usage — won't compile if caller omits `error`
const { data, error } = await mutate(
supabase.from('contacts').update({ statut: 'ancien' }).eq('id', id)
)
if (error) throw new Error(error.message)
TypeScript does its job. And when error is non-null, Sentry gets a breadcrumb immediately with the PG code and details — before the caller even decides what to do. I see the remaining violations roll through review the next morning. The lie is no longer possible: it's either forbidden by the compiler or observed by instrumentation. I prefer both.
The cost? An indirection layer on all mutations, meaning a new dev joining the project who writes await supabase.from(...).update(...) doesn't compile. Productive friction. Whatever you think of the verbosity, it beats a lying green toast.
What Supabase didn't invent
The problem isn't specific to Supabase, and that matters for anyone coding in other ecosystems. The return-value-error trap swallows silently in fetch().ok that you forget to test before reading response.json(), in child_process.spawn that returns a non-zero exit code without throwing, in certain Stripe SDK versions where the difference between a network error and a response error lives in a field you have to go find. Supabase is the showcase for a wider contract.
The rule to draw out goes beyond Supabase and fits in one sentence. A return that can carry an error without raising it is a lie for as long as no material mechanism forces the caller to read it. Discipline alone isn't enough; linter rules alone aren't enough; typing that makes the field non-optional, coupled with instrumentation that catches workarounds, finally holds.
Coda
A return-value-error with no reader isn't an error, it's a rumor. Wednesday evening, I pushed the wrapper, re-archived Pauline's twenty contacts in an operation that this time properly raised two exceptions on misconfigured roles. The toast appeared red. Pauline sighed — economically — and fixed the permissions in two clicks. The rumor had become information.
Thursday — episode three [CANONICAL URL EPISODE 3: to complete after push] — another form of the same lie. An RLS policy that returns zero rows without saying why, and what I ended up putting in place of my policies after a full day of debugging.
Episode 2 of the mini-series "The week Supabase lied to me 4 times". The typed mutate() wrapper, pseudonymized: github.com/michelfaure/rembrandt-samples/tree/main/supabase-mutate-typed-wrapper
Top comments (0)