@boldsec/supabase
v0.1.0
Published
BoLD live authorization monitoring for Supabase: a fetch-level wrapper for supabase-js watches your PostgREST/RPC traffic for cross-user access (BOLA/IDOR) and mass-assignment (BOPLA) risks. Metadata only, fail-safe, zero query-builder patching.
Maintainers
Readme
@boldsec/supabase
Live BOLA/IDOR + mass-assignment (BOPLA) monitoring for Supabase. Install one fetch wrapper at
the createClient({ global: { fetch } } ) boundary; BoLD watches your real PostgREST/RPC traffic and
raises an alarm the moment one user reaches another user's row, in real time, from your app's actual
traffic, no query-builder patching, no schema introspection, no dependency on @supabase/supabase-js
itself.
- Metadata only. BoLD receives who made the request (the Supabase Auth
subclaim, decoded from the request's own bearer JWT, never the token itself), the canonical endpoint shape (/rest/v1/<table>/{id}), the object id, the status, and the object's declared owner column if the response exposed one. The response row is read once to extract that single owner field and then discarded. No body, no credentials, and no Supabaseservice_role/anon key ever leave your app. - Fail-safe. The real Supabase call always runs first and its response is always returned completely untouched. Shipping the metadata can never throw back to your app or corrupt its response. It DOES add one more network round trip, to BoLD's own ingest endpoint, on a call BoLD observes: the wrapper AWAITS that shipment (a deliberate choice, not an oversight) so the metadata is not silently dropped on a serverless/edge runtime that freezes the moment a response returns. A down or slow ingest endpoint still never breaks your app; it can only ever add latency to the observed call, never fail it.
- Never patches the query builder.
wrapSupabaseFetchis a plainfetch-compatible function, installed via supabase-js's own documentedglobal.fetchoption, a stable, public escape hatch, not an undocumented internal.
Install
npm install @boldsec/supabaseWorks with @supabase/supabase-js v2 (peer, not a dependency of this package, there is none).
Configure (once)
Add your keys from the BoLD Live page (shown once when you connect the app):
BOLD_INGEST_URL=https://<your-bold>/api/live/ingest
BOLD_INGEST_KEY=blk_...
# optional: owner-field names to look for in responses (default covers common ones, incl. owner_id)
BOLD_OWNER_FIELDS=ownerId,owner_id,user_id
# optional: your primary-key column name, if it is not "id"
BOLD_SUPABASE_ID_COLUMN=idUse
Wrap the fetch you hand to createClient, on a server-side Supabase client only (see the
browser-runtime note below):
import { createClient } from "@supabase/supabase-js";
import { wrapSupabaseFetch } from "@boldsec/supabase";
const supabase = createClient(supabaseUrl, supabaseKey, {
global: { fetch: wrapSupabaseFetch() },
});Unconfigured (no BOLD_INGEST_URL), the wrapper is a transparent pass-through, so it is safe to leave
wired in every environment.
SERVER-SIDE ONLY (a load-bearing constraint, not a suggestion)
BOLD_INGEST_KEY is BoLD's own ingest credential and must never ship inside a browser bundle. Every
other BoLD adapter is structurally server-only (Express/Fastify/Koa/Hono/Nest middleware, a Next.js
route handler); supabase-js is unusually often instantiated directly in the browser (Supabase's own
"no backend required" pattern). wrapSupabaseFetch defends against this proactively: it refuses to
observe at all when it detects a runtime it must not ship a secret from, a main-thread browser tab, a
Web Worker (dedicated, shared, or service worker), or, as a broader third signal, the absence of a
Node-like process.versions.node. The real Supabase call is completely unaffected either way, only
BoLD's own observation is skipped. Wire this into a server-side Supabase client (a Route Handler, a
Server Component, a Node backend). A purely client-side/browser/worker Supabase integration is not
observed by this connector in this phase.
The runtime guard blocks the SHIP, not the BUNDLE. This is a real distinction, not a nuance to
skim past: the guard stops BoLD's own code from ever making the network call that would send
BOLD_INGEST_KEY anywhere, at runtime. It does nothing to keep the KEY ITSELF out of a browser
bundle's compiled source in the first place, that is a build-time concern, entirely on the operator:
- Never pass
ingestKeyas a literal string inline in a module that a bundler (webpack, Vite, Next's client compiler) can reach from client-side code, even behind anif (typeof window === "undefined")guard in your OWN code, a bundler can still statically include the literal string in the shipped file; only code that is NEVER imported by a client entry point is safe. - Never expose
BOLD_INGEST_KEYthrough a "public"/inlined build-time env var convention (NEXT_PUBLIC_BOLD_INGEST_KEY,VITE_BOLD_INGEST_KEY, or similar). Those conventions exist specifically to BAKE a value into the client bundle at build time; using one for this key defeats the runtime guard entirely, since the secret is already sitting in plain text in shipped JS before the guard ever runs. SetBOLD_INGEST_KEYas an ordinary, server-only environment variable, read only from a module your server entry points import, never a client one.
Use the caller's own per-user, RLS-enforcing session, never a service_role client. Wrap a
Supabase client authenticated as the actual signed-in user (their forwarded access token), not one
authenticated with the service_role key. Wrapping a service_role client is both an anthem risk and
a signal-quality problem, not merely a style preference:
service_rolebypasses Row Level Security entirely, so every read this connector observes through it "succeeds" regardless of who owns the row, RLS is not actually being exercised on that traffic at all, and any cross-user comparison drawn from it says nothing true about your app's real enforcement.service_role's JWT carries no per-usersubclaim, it identifies the SERVICE, not a caller. Every request wrapped through it decodes to the SAME caller identity (or falls back to the same constant hash, since the bearer string never changes between requests), so the connector can no longer tell one real user's access from another's at all. The result is needs-review noise, not a usable signal, never a meaningful CONFIRMED or a meaningful clean, for any traffic sent through that client.
What BoLD answers
One question: can one user reach another user's row? BoLD is a runtime alarm for this family (BOLA/IDOR, function-level BFLA, mass-assignment BOPLA, tenant isolation), never a general scanner. A Supabase cross-user row read is judged by the exact same deterministic decision logic every other BoLD connector uses.
What this connector sees, and what it does not (honest ceilings)
- Table reads/writes (
.select()/.update()/.delete(), and a create only when it also carries an id-equality filter) are observed only when the request's own query string proves it targeted exactly one object, an<id_column>=eq.<value>filter (defaultid). Any other filter operator, no filter at all, anor=(...)combinator, or a filter matching more than one row all ship nothing, BoLD never guesses which row a list-shaped or ambiguous request touched. - A bare
.insert()(a table create with no.eq()/.match()) is never observed in this phase. Supabase creates carry no query-string filter to gate on, so this connector cannot yet attribute a create to a single object. Mass-assignment on a create specifically (e.g. a self-registration setting its ownrole) is not caught by this passive wrapper today;.update()writes (the common pattern for an existing object) are fully covered. - A write whose response has no readable body (the PostgREST default
204 No Contenta plain.delete()/.update()gets withoutPrefer: return=representation) is not observed, there is no row to read an id/owner from. Chain.select()after the write (the idiomatic way to opt intoreturn=representation) and it is observed. - RPC (
.rpc()) coverage is narrow, by design. Only an RPC whose response is a single, row-shaped JSON object, with both a real (non-null) id AND a real, extractable owner value, is observed. Asetof-returning function's array response, a scalar-returning function's bare value, and (the recorded, load-bearing case) areturns rowtypefunction's all-nullcomposite on a zero-row/ RLS-blocked result all ship nothing. RPC parameters are never inspected for id/owner/BOPLA purposes, only the response. - Storage (
supabase.storage) ships nothing in this phase. Alist()call's metadata array carries no per-object owner field in Supabase's default schema; adownload()'s raw bytes are never parsed. /auth/v1/...is never inspected, beyond decoding thesubclaim off the request's own bearer JWT for caller identity (no signature verification, Supabase's own gateway already verified it to produce the response at all). A password, an OTP, or a refresh token is never read or forwarded.- This connector only sees calls made through the Supabase-JS client instance it wraps. A direct PostgREST HTTP call from another client/language, a call from a different backend service using its own Supabase client, and the native mobile SDKs (Swift/Kotlin/Flutter) are not observed. This is a real, named coverage boundary ("wrapped calls only"), never folded into a generic all-clear.
The locked claim, everywhere this connector's findings are surfaced: "verify live cross-user access," never "verify your RLS policy." BoLD proves what actually happened over the wire to a real request, a different, stronger claim than a static policy audit, which is Supabase's own Advisor's job.
Development
npm test, unit tests (src/index.test.ts) plus the recorded-ground-truth fixture suite (src/ground-truth.test.ts, byte-for-byte fromground-truth/wire-behavior.mdandground-truth/RPC_STORAGE_BEHAVIOR.md).node conformance.mjs, the Supabase-only golden-vector conformance gate (../bold-conformance/test-vectors-supabase.json).npm run test:live, an optional, NOT-CI-wired end-to-end test against a real local Supabase CLI stack (supabase start), for local development only.
