Authentication and JWT verification

What the Require a JWT setting checks on SupaHost, why the anon key passes it, how to identify the signed-in user, restrict a function to service_role, and handle public webhooks.

Every Edge Function has a Require a JWT setting, on by default. It's a cheap first gate, but it doesn't tell you who is calling. This page explains what the check does and how to add the checks your function actually needs.

What "Require a JWT" checks

When the setting is on, SupaHost's functions router looks at the Authorization: Bearer <token> header before your code runs. It verifies that the token:

  • is an HS256 JWT signed with your project's JWT secret, and
  • hasn't expired (exp) and isn't used before its nbf time.

If the header is missing the caller gets 401 with sb-error-code: UNAUTHORIZED_NO_AUTH_HEADER. If the token is invalid or expired, 401 with UNAUTHORIZED_INVALID_JWT. Your function never sees those requests.

Any valid JWT from the project passes, including the public anon key. Since the anon key ships in your frontend, "Require a JWT" on its own means "the caller knows this project's public key", not "the caller is signed in". OPTIONS requests skip the check so browser preflights work (see CORS for browser calls).

You can change the setting per function: the Require JWT toggle in the Functions table, the Require a JWT switch in the editor, or verify_jwt in the MCP deploy_edge_function tool.

Identify the signed-in user

When a signed-in user calls your function through supabase-js, the Authorization header holds their access token. Ask Auth who it belongs to with getUser, which validates the token with the Auth server:

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

Deno.serve(async (req) => {
  const authHeader = req.headers.get("Authorization") ?? "";
  const token = authHeader.replace(/^Bearer\s+/i, "");

  const supabase = createClient(
    Deno.env.get("SUPABASE_URL")!,
    Deno.env.get("SUPABASE_ANON_KEY")!,
    { global: { headers: { Authorization: authHeader } } },
  );

  const { data: { user }, error } = await supabase.auth.getUser(token);
  if (error || !user) {
    return Response.json({ error: "Sign in first" }, { status: 401 });
  }

  // This client sends the user's token, so Row Level Security applies to every query.
  const { data: invoices, error: dbError } = await supabase
    .from("invoices")
    .select("id, number, total, status")
    .eq("status", "unpaid");
  if (dbError) return Response.json({ error: dbError.message }, { status: 400 });

  return Response.json({ user: user.email, unpaid: invoices });
});

Sending the anon key instead of a user token makes getUser return an error, so the function answers 401. Do this check in any function that costs money per call (AI models, email, SMS) or touches one user's data.

Read Sessions, JWTs and refresh tokens for what's inside the access token, and Roles, custom claims and RBAC if you put roles in app_metadata. user.app_metadata is available on the object getUser returns.

Allow only server-side callers

Some functions should only be called by your own backend or by a cron job, never from a browser. Keep Require a JWT on and check that the token's role claim is service_role. Because the router has already verified the signature, it's safe to read the claims directly:

function jwtRole(req: Request): string | null {
  const token = req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
  const payload = token?.split(".")[1];
  if (!payload) return null;
  try {
    const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
    return JSON.parse(json).role ?? null;
  } catch {
    return null;
  }
}

Deno.serve(async (req) => {
  if (jwtRole(req) !== "service_role") {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
  // ... nightly clean-up work ...
  return Response.json({ ok: true });
});

Only decode claims yourself when Require a JWT is on. With it off, anyone can send a token with any claims they like.

The caller sends the service_role key as the bearer token. From SQL, keep that key in Vault: see Running functions on a schedule.

Public webhooks: turn the check off

Stripe, GitHub, Resend and similar services can't send your project's JWT. For those endpoints, turn Require a JWT off and verify the sender yourself, using the provider's signature header and a signing secret stored in function secrets. Every request to a function with the check off reaches your code, so reject anything without a valid signature before doing work. A complete example is in Example: Stripe webhooks.

The /functions/v1/* paths don't need an apikey header on SupaHost, so webhook senders only have to reach the URL.

Quick reference

Caller Require a JWT Check in your code
Signed-in users of your app On supabase.auth.getUser(token)
Anyone with your app (public, low cost) On None needed
Your server or cron job On role claim is service_role
Third-party webhook Off The provider's signature

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us