BlogEngineering

Two Supabase mistakes that fail silently, and how to catch both

A camelCase column breaks every insert with PGRST204. Supabase's new API keys are not JWTs and belong in the apikey header. Neither mistake has to surface in your app.

Writing to a Supabase table over the REST API has two traps that fail quietly: PostgREST matches JSON keys to column names exactly, so one camelCase field breaks every insert, and the newer sb_secret_… keys are not JWTs, so they belong in the apikey header; anything that verifies the Authorization header as a JWT, such as Edge Function code, fails on one. Both failures happen on Supabase's side of the request, not in your code, and neither necessarily produces an error in your app.

The short version

  • PostgREST maps a JSON key to a column of the same spelling. receivedAt needs a column created as "receivedAt", with the quotes. Unquoted, Postgres folds it to receivedat and every insert fails with PGRST204.
  • Legacy service_role keys are JWTs and go in both apikey and Authorization: Bearer. The newer sb_secret_… keys are opaque, not JWTs, and belong in apikey alone.
  • Both failures happen on Supabase's side of the request, not in your code. If your write helper swallows errors — a reasonable thing for it to do — your application reports success and loses the row.

Trap 1: PostgREST does not convert your casing

Post a body to /rest/v1/<table> and PostgREST takes each JSON key as a column name verbatim (PostgREST, error reference). There is no camelCase-to-snake_case translation step. So this payload:

{ "email": "a@example.com", "receivedAt": "2026-09-11T22:20:02.865Z" }

needs a column literally named receivedAt. Postgres folds unquoted identifiers to lower case (PostgreSQL, identifiers), so the obvious DDL creates the wrong thing:

-- Creates "receivedat". Every insert above now fails.
create table public.leads (receivedAt timestamptz);
 
-- Creates "receivedAt". Correct.
create table public.leads ("receivedAt" timestamptz);

The error is specific once you see it:

PGRST204  Could not find the 'receivedAt' column of 'leads' in the schema cache

Two ways out, and the choice is worth making deliberately. Quote the identifier and keep the casing your application already sends, which is what we did — it keeps the write path free of a mapping layer, at the cost of a column you must always quote in SQL. Or use snake_case columns and translate in the client, which is tidier in the database and adds a place where a field can be forgotten.

Either is fine. Choosing neither, and discovering the mismatch from production inserts, is the expensive option.

Trap 2: the new keys are not JWTs

Supabase's legacy anon and service_role keys are HS256 JWTs, so either one works in Authorization: Bearer, including the usual shape of sending the same key in both apikey and Authorization: Bearer.

The newer keys do not have that shape. sb_secret_… and sb_publishable_… are opaque strings. Supabase allows one in Authorization: Bearer only when it exactly matches the apikey header, as a backward-compatibility allowance (Supabase, discussion #29260), and anything that verifies that header as a JWT fails on it (Supabase, API keys).

A helper that sends the same key in both headers keeps working on the REST API after rotation. What breaks is anything that puts a new-format key in Authorization: Bearer next to a different apikey value, or hands that header to code that expects a JWT. Send new-format keys on apikey alone, as Supabase recommends:

export function authHeaders(key: string): Record<string, string> {
  const opaque = key.startsWith("sb_");
  return opaque
    ? { apikey: key }
    : { apikey: key, Authorization: `Bearer ${key}` };
}

Two things worth saying about that function. It decides transport, never privilege — a publishable key takes the same path and runs as the anon role, so your RLS policies decide what it can do. And it keeps the code on the header shape Supabase documents before the legacy keys are retired, which Supabase currently plans for late 2026.

Why both of these are quiet

A write helper that never throws is good design. A capture failure should not take down a request that had other work to do. But "never throws" and "never reports" are different, and it is easy to ship the first while accidentally shipping the second:

const stored = await recordLead(lead);   // null on success, a code on failure
if (stored !== null && stored !== "NOT_CONFIGURED") {
  console.error("[contact] lead store failed", { stored });
}

Without that check, whenever the rest of the request succeeds, a store refusal returns the same 200 as a healthy write, and nothing records why the row is missing.

How to verify, in about a minute

Do not trust your own success path. Go to the data.

-- Every column, with its real spelling.
select column_name, data_type
from information_schema.columns
where table_schema = 'public' and table_name = 'leads';

Then write one row through the real endpoint and select it back. On our lead store this check confirmed both: the quoted receivedAt column accepted the value, and a publishable key sent with apikey alone reached PostgREST and was refused by RLS — [] on select, 42501 on insert. That second result is the one worth copying. It proves the key format reaches the database and that the table is closed to anything but the server's own key.

Operate what ships

Treat silent failure as an operating problem.

See how monitoring, incident response, controlled changes, and handoff fit together.

See managed systems

Built by LOJIK

Things we built. Samples of what we can build for you.

We built these for ourselves first. What we build for you is shaped around your business.

Things we build

  • Apps
  • Internal tools
  • Automations
  • Integrations
  • Customer portals
  • AI agents