401 invalid claim: missing sub claim

getUser() fails with "invalid claim, missing sub claim" because the token it checked is the anon key, not a user's access token. How to spot it in servers and Edge Functions.

What you see

A call to supabase.auth.getUser() fails, most often in server code or an Edge Function:

AuthApiError: invalid claim: missing sub claim

The response has error_code: "bad_jwt". Depending on the Auth version and how your code reports it, the status is 401 or 403.

Why it happens

getUser() sends a token to Auth and asks which user it belongs to. A user's access token carries that user's id in its sub claim. The anon and service_role keys are also JWTs, but they have no sub, because they don't represent a user. Pass one of them to getUser() and Auth can't name a user, so it rejects the token.

Common ways a key ends up in place of a user token:

  • Nobody is signed in. The client then sends the anon key in the Authorization header, and your server or function forwards that header to getUser().
  • supabase.functions.invoke() ran before sign-in finished, so the request carried the anon key.
  • Your code passed the key, not session.access_token, to getUser(token).
  • A server client was created with the service_role key and getUser() was called on it without a token.

How to fix it

  1. Treat it as "not signed in." In a function or API route, return 401 instead of letting the error bubble up as a 500:

    import { createClient } from "npm:@supabase/supabase-js@2";
    
    Deno.serve(async (req) => {
      const token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
      const supabase = createClient(
        Deno.env.get("SUPABASE_URL")!,
        Deno.env.get("SUPABASE_ANON_KEY")!,
        { global: { headers: { Authorization: `Bearer ${token}` } } },
      );
    
      const { data, error } = await supabase.auth.getUser(token);
      if (error || !data.user) {
        return Response.json({ error: "Sign in to save recipes" }, { status: 401 });
      }
    
      const { data: saved } = await supabase
        .from("saved_recipes")
        .select("recipe_id")
        .eq("user_id", data.user.id);
      return Response.json(saved);
    });
    
  2. Only call user-only endpoints with a session. On the client, check first:

    const { data: { session } } = await supabase.auth.getSession();
    if (!session) return showSignIn();
    await supabase.functions.invoke("saved-recipes");
    
  3. Pass the access token, not a key, when you call getUser(token) yourself.

  4. Keep admin and user clients separate. A client built with service_role is for admin work. To find out who is calling, check the caller's token with a client built from the anon key, as in step 1.

On SupaHost

An Edge Function's Require JWT toggle (on by default) checks that the request carries a valid JWT signed with the project's secret. The anon key passes that check. So a request can get through the gateway and still have no user behind it. If a function acts for a user, call getUser() in the function as shown above.

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