# 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.

Source: https://supahost.app/docs/functions/auth · Updated 2026-11-11

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](/docs/functions/cors)).

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:

```ts
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](/docs/auth/sessions) for what's inside the access token, and [Roles, custom claims and RBAC](/docs/auth/custom-claims-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:

```ts
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](/docs/functions/scheduling).

## 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](/docs/functions/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](/docs/functions/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 |

## Related

- [Edge Function returns 401](/kb/function-401)
- [Calling functions from your app](/docs/functions/calling-functions)
- [Using the database from a function](/docs/functions/database-access)
- [Securing your API](/docs/api/securing-your-api)
