← All posts
·7 min read·GuardLayer

Edge Function returned a non-2xx status code: real error

SupabaseEdge FunctionsAuthDebugging

"Edge Function returned a non-2xx status code" is supabase-js telling you the function answered with an error status. It isn't the error itself. The real status and response body are on error.context, so read them before you change anything, and never fix it by deleting the function's auth check.

What does "Edge Function returned a non-2xx status code" mean?

It means your Edge Function answered with a status outside 200-299, and supabase.functions.invoke() wrapped that response in a FunctionsHttpError with a fixed message. The string is hard-coded in supabase-js's functions-js/src/types.ts, and invoke() in FunctionsClient.ts can return three errors:

  • FunctionsFetchError, "Failed to send a request to the Edge Function": the fetch itself failed, so there's no response at all.
  • FunctionsRelayError, "Relay Error invoking the Edge Function": the response carried an x-relay-error: true header.
  • FunctionsHttpError, this one: if (!response.ok) throw new FunctionsHttpError(response).

So the client passes the raw Response in as context and never reads its body. Whatever your function or the platform said is still in it. Apps built with Lovable typically call their functions through the same invoke(), which is why the message shows up there too.

How do I see the real error?

Check the error class, then read error.context. The invoke docs in supabase-js show this pattern; here it is with the status and header logged too:

import {
  FunctionsHttpError,
  FunctionsRelayError,
  FunctionsFetchError,
} from "@supabase/supabase-js";

const { data, error } = await supabase.functions.invoke("charge", {
  body: { orderId },
});

if (error instanceof FunctionsHttpError) {
  const res = error.context as Response;
  const body = await res.text(); // a body can be read once
  console.error(res.status, res.headers.get("sb-error-code"), body);
} else if (error instanceof FunctionsRelayError) {
  console.error("relay error", error);
} else if (error instanceof FunctionsFetchError) {
  console.error("no response", error.context);
}

Check instanceof first: a FunctionsFetchError wraps the fetch error, not a Response, so .json() on its context throws. Prefer .text() unless you know the body is JSON. Then match the request in the function's dashboard logs.

Triage by status code

Supabase's status codes page lists what the platform returns. Error responses can also carry an sb-error-code header naming the condition.

StatusWhat it meansWhere to look
401The platform's JWT check failed, or your own code returned 401Function logs: if your handler logged nothing, the platform rejected it
404Wrong function name or project ref, or not deployedsupabase functions list
500Uncaught exception in your code (WORKER_ERROR)Function logs, stack trace
503The function failed to boot (BOOT_ERROR)Imports, syntax, config; run supabase functions serve
504No response within the request timeoutSlow queries or external APIs
546A resource limit was exceeded (WORKER_RESOURCE_LIMIT)Logs name the limit

Any other status is one your handler returned, such as a 400 from your own validation, with your body in error.context.

A CORS failure is a different case. If the browser blocks a response for missing CORS headers, fetch rejects, so you most likely get FunctionsFetchError rather than this message (our inference from the code path). Supabase CORS errors in Next.js covers that fix.

The fix that opens the function

The 401 is where this goes wrong. Your function returns 401 when it can't verify the caller, the UI shows "non-2xx", and the quickest way to make the message go away is to delete the check. An AI builder told to "fix the error" can land on the same edit, because it does stop the message:

import { createClient } from "npm:@supabase/supabase-js@2";

const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

Deno.serve(async (req) => {
  // The user check returned 401, and the app showed "Edge Function
  // returned a non-2xx status code". Removed it. Checkout works now.
  const { orderId } = await req.json();

  const { error } = await admin
    .from("orders")
    .update({ status: "paid" })
    .eq("id", orderId);

  if (error) return Response.json({ error: error.message }, { status: 500 });
  return Response.json({ ok: true });
});
guardlayer scan · supabase/functions/charge/index.tsLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/functions/charge/index.ts:5

    Edge function without auth validation

    Verify the caller inside the function. For user calls, wrap the handler in withSupabase({ auth: 'user' }, handler) from @supabase/server, or read the Authorization header, call supabase.auth.getUser(token) or auth.getClaims(token), and reject when there is no user. For cron or internal calls, use withSupabase({ auth: 'secret' }) (or 'secret:<name>') or compare a dedicated secret header to a server-side secret, and fail closed when that secret is unset. For provider webhooks, verify the provider's signature. The 'none' and 'publishable' modes do not authenticate the caller: a publishable key ships in every client. verify_jwt = true alone is not enough, because the public anon key is a valid JWT.

The function runs with the service role key, which bypasses RLS, and it now trusts whatever orderId arrives. Anyone who POSTs to https://<project-ref>.supabase.co/functions/v1/charge can mark an order paid if they have its ID, starting with their own. Your project ref is in every client bundle, and Edge Functions are effectively public by default: the platform's JWT check also accepts the public anon key.

Turning off JWT verification is the same shortcut one layer up. Supabase's status codes page limits it to two cases: "For webhooks or public endpoints, consider disabling JWT verification". A checkout function is neither. If the 401 comes from the platform check, fix 401 Invalid JWT without opening the function walks through it.

How do I fix it without removing the check?

Keep the check, and make its 401 say why. Read the token, verify it, and return a JSON body your client can show:

Deno.serve(async (req) => {
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
  if (!token) {
    return Response.json({ error: "Not signed in" }, { status: 401 });
  }
  const { data, error: authError } = await admin.auth.getClaims(token);
  // A legacy anon key is a valid JWT too, so require a user ID.
  if (authError || !data?.claims?.sub) {
    return Response.json({ error: "Session expired. Sign in again." }, { status: 401 });
  }

  // The rest of the handler, scoped to data.claims.sub.
});

invoke() sends the user's access token automatically. With no session, supabase-js sends your project's public key instead, hence the sub check. So a 401 here usually means the call ran before sign-in finished or after the session ended. Your client now reads the reason from error.context.

This fix has a limit. Even with the check back, a signed-in user could mark their own unpaid order as paid. Payment status should come from Stripe's verified webhook, never from a function the browser calls.

Stripe: same message, different function

Stripe calls your webhook function directly, so this supabase-js message can't come from the webhook. When it appears in a Stripe flow, it's most likely the checkout function your browser invokes to create a session (our inference). Two candidates to check: the Stripe secret key was never set with supabase secrets set, or the price ID belongs to the other mode (test versus live). Either one fails the function, and error.context plus the function logs show which.

The webhook function has no user JWT, so it needs verify_jwt = false and a signature check. Supabase's own Stripe example verifies with stripe.webhooks.constructEventAsync and Stripe.createSubtleCryptoProvider(), because it checks the signature through the Web Crypto API, which is async. The raw-body rules are the same as in Stripe webhook signature verification.

What GuardLayer catches, and what it doesn't

GuardLayer flags the deleted-check version above as supabase/edge-function-no-auth, a warning on the Deno.serve line: a handler in supabase/functions/ that never reads the Authorization header and verifies it. Restoring the getClaims check clears it. So does withSupabase({ auth: "user" }) from @supabase/server, a secret-header check or a provider signature check.

It doesn't read whether your client handles FunctionsHttpError, can't see the dashboard's Verify JWT toggle or production statuses, and won't flag the payment-status design problem above.

Quick self-check

# Functions that never read the Authorization header
grep -iL "authorization" supabase/functions/*/index.ts

# Functions with the platform check turned off
grep -n -B1 "verify_jwt = false" supabase/config.toml

FAQ

What does "Edge Function returned a non-2xx status code" mean? Your Supabase Edge Function returned a status outside 200-299. supabase-js wraps the response in a FunctionsHttpError; the real status and body are on error.context.

How do I get the actual error message from a Supabase Edge Function? Check error instanceof FunctionsHttpError, then await error.context.text() (or .json()) for the body and error.context.status for the code. The body can be read once.

Should I disable JWT verification to fix it? Not for a function your users call. Find out which status you got first. If it's a 401 from the platform check, verify the caller in code before you turn the platform check off.

Why do I get this error with Stripe? From the checkout function the browser invokes, not the webhook, which Stripe calls directly. Check for a missing Stripe secret or a price ID from the wrong mode, and read error.context and the function logs.

See which of your Supabase tables anyone can read, free

Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.

Check my Supabase project

Prefer to check code instead? Scan your repo, no signup.

Keep reading