← All posts
·6 min read·GuardLayer

Supabase Edge Functions are public by default — verify the JWT

SupabaseEdge FunctionsAuthJWT

A Supabase Edge Function is a Deno handler sitting behind a public URL. The moment you run supabase functions deploy send-invite, anyone on the internet can POST to https://<project-ref>.supabase.co/functions/v1/send-invite. There is no firewall, no allow-list, no implicit "must be logged in" gate wrapped around your code. If your handler does work without checking who is calling, that work is now a public API.

This catches people out because the rest of Supabase trains you to lean on Row Level Security. With the client SDK, an unauthenticated request is naturally constrained by your policies. Edge Functions break that intuition: when you build the client with the service_role key — which most functions do, because they need to do privileged work — you have stepped completely outside RLS. The function is the trust boundary now, and by default there is nothing standing on it.

What "public by default" actually means

One setting can gate a function before your code runs: verify_jwt. When it is on, the Edge runtime rejects any request that lacks a valid Authorization: Bearer <jwt> header. It defaults to true for every deployed function — whether you ship via the CLI or the dashboard.

But it is routinely turned off, and for a real reason: webhooks (Stripe, GitHub), public contact forms, and anything called by a third party cannot send a Supabase JWT. So people disable verify_jwt for that one function and forget there was ever a gate.

The trap is the in-between case: a function meant to be called by your logged-in users, deployed with verify_jwt off, that never re-checks auth in code. That is a function doing privileged work for anyone who finds the URL.

Here is the canonical version — an invite endpoint that writes to the database and sends email, with no idea who is calling it:

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

// Deployed at /functions/v1/send-invite — anyone can POST to it.
Deno.serve(async (req) => {
  const { email, orgId } = await req.json();

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

  await supabase.from("invites").insert({ email, org_id: orgId });
  await sendInviteEmail(email, orgId);

  return new Response(JSON.stringify({ ok: true }), {
    headers: { "Content-Type": "application/json" },
  });
});

This deploys cleanly. It works when your app calls it. And it lets a stranger add themselves to any org by guessing an orgId, or blast invite emails from your domain on demand. Here is what GuardLayer reports on exactly that file — live engine output, not a mockup:

guardlayer scan · supabase/functions/send-invite/index.tsLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/functions/send-invite/index.ts:1

    Edge function without auth validation

    Read the Authorization header and verify the JWT (supabase.auth.getUser(token)) before doing any work, or set verify_jwt = true in the function config.

Why this is worse than an ordinary missing auth check

Three factors stack up:

  • The service_role key amplifies it. Every query inside this function bypasses RLS. Even if your tables are locked down perfectly, this handler ignores all of them — an unauthenticated caller effectively acts as the database owner.
  • It is discoverable. Function URLs follow a fixed pattern (/functions/v1/<name>) and the names are predictable (send-invite, create-checkout, delete-account). They show up in your client bundle, your network tab, and your repo. There is no security in the URL being "obscure."
  • It is silent. Nothing errors. Your own app works because it happens to send a valid token. You learn the endpoint is open only when someone abuses it — spam invites, forged records, or worse on a function that mutates data.

The fix: read the header, verify the token

Reading the Authorization header is necessary but not sufficient — you have to actually verify the token, because the header is attacker-controlled. The cleanest way is to hand the token to Supabase and let it validate the signature and expiry:

import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

Deno.serve(async (req) => {
  const authHeader = req.headers.get("Authorization");
  if (!authHeader) {
    return new Response("Unauthorized", { status: 401 });
  }
  const token = authHeader.replace("Bearer ", "");

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

  // Verifies the JWT signature + expiry against your project.
  const { data: { user }, error } = await supabase.auth.getUser(token);
  if (error || !user) {
    return new Response("Unauthorized", { status: 401 });
  }

  // `user` is now trusted. Authorize the action before doing it.
  const { email, orgId } = await req.json();
  // ...check that `user` is actually a member/admin of `orgId`...

  await supabase.from("invites").insert({
    email,
    org_id: orgId,
    invited_by: user.id,
  });
  await sendInviteEmail(email, orgId);

  return new Response(JSON.stringify({ ok: true }), {
    headers: { "Content-Type": "application/json" },
  });
});

Two things changed and both matter:

  1. Authentication — we read the header and call supabase.auth.getUser(token), which verifies the signature and expiry. A forged or expired token now returns 401.
  2. Authorization — knowing who the user is is not enough; we still check they are allowed to invite into that orgId. Authentication tells you the caller is real; authorization tells you they are permitted. Do not ship one without the other.

If the function genuinely should require any logged-in user and nothing more, leave verify_jwt = true (the default) in supabase/config.toml and let the runtime reject anonymous calls before your code runs. Use that as the gate, then still do the authorization check inside.

When the function is meant to be public

Webhooks and public forms are real, and for those you cannot require a Supabase JWT — so you set verify_jwt = false. But "public" is not the same as "unverified":

  • Webhooks: verify the provider's signature (Stripe's Stripe-Signature, GitHub's X-Hub-Signature-256) against your signing secret. That is your auth.
  • Public forms: keep the function tiny, rate-limit it, validate input hard, and do not hand it the service_role key if a scoped anon flow will do.

The rule is not "every function must check a JWT." It is "every function must verify something about the caller before doing privileged work." A bare Deno.serve that trusts its input is the thing to avoid.

30-second self-check

# Functions that handle requests but never touch the Authorization header
grep -rL "Authorization" supabase/functions --include="index.ts"

# Functions using the service_role key (highest blast radius — check these first)
grep -rln "SERVICE_ROLE_KEY" supabase/functions

# Which functions have verify_jwt explicitly turned off?
grep -rn "verify_jwt" supabase/config.toml

Any function in the first list that also appears in the second is doing privileged work for the entire internet. Fix those today.

FAQ

Does not verify_jwt protect me automatically? Only while it is on for that function. It defaults to true, but it is commonly disabled for webhooks and public endpoints, and the setting is per-function. If it is off, your code is the only gate — so read and verify the token yourself.

Is reading the Authorization header enough? No. The header is attacker-supplied. You must verify the token's signature and expiry — supabase.auth.getUser(token) does this. A check that merely reads the header without verifying is theater.

My function uses the anon key, not service_role. Am I safe? Safer, because RLS still applies to its queries. But the function can still perform actions (send email, call third-party APIs, run logic) that RLS does not cover. Authenticate the caller regardless.

The function is only called from my own frontend — who would find it? Anyone reading your JS bundle or network tab. The URL is right there. "Only my app calls it" is an assumption, not a control.

Catch this before it ships — free

GuardLayer scans every push for this and 19 other Next.js + Supabase issues, with the exact fix inline.

Keep reading