Noções de segurança
Supabase "invalid claim: missing sub claim": ninguém está logado
"invalid claim: missing sub claim": o Supabase Auth recebeu um token autêntico sem nenhum usuário, a chave do projeto no lugar de uma sessão.

Em resumo
- "invalid claim: missing sub claim" é o Supabase Auth respondendo 403 com o código bad_jwt. Perguntaram a ele quem está chamando, o token que recebeu era autêntico e não nomeava ninguém.
- A própria página de soluções do Supabase aponta a causa comum: uma chave de API enviada onde deveria ir o token de acesso de um usuário logado. Uma chave diz qual app está chamando e não tem nenhuma pessoa dentro.
- Chame a função só quando alguém estiver logado, e faça ela responder 401 quando ninguém estiver. Trocar pela chave secreta não muda nada, porque essa chave também não nomeia ninguém.
O seu app tem uma Supabase Edge Function que faz algo pela pessoa que o usa, como salvar as configurações dela ou chamar a OpenAI em nome dela. Nos seus testes, funcionava. Aí o log da função começa a encher com esta linha, e o app mostra um erro ou nada:
AuthApiError: invalid claim: missing sub claim
Pesquise "invalid claim: missing sub claim" e vão dizer para você gerar suas
chaves de novo, desligar o Verify JWT ou colocar a chave service_role. Cada um
desses conselhos trata o token como quebrado. O token estava certo. O
Supabase verificou a assinatura e aceitou. O que faltava nele era um nome,
qualquer um.
O que significa "invalid claim: missing sub claim"?
Perguntaram ao Supabase Auth qual usuário está chamando; ele achou o token autêntico e nenhum usuário escrito nele.
Os tokens que o Supabase Auth lê são JWTs: três blocos de texto separados por
pontos, em que o bloco do meio são dados legíveis. Cada item desses dados se
chama claim. sub, abreviação de subject, é o claim que guarda o id de um
usuário, e a
referência de JWT do Supabase
descreve ele como o ID do usuário, um UUID.
A mensagem está escrita no próprio código-fonte do Supabase Auth, numa função
chamada maybeLoadUserOrSession em internal/api/auth.go. Quando essa função
roda, a assinatura do token já foi verificada e passou. Ela então procura o
sub, e quando o claim está vazio ela para com um HTTP 403, o código de erro
bad_jwt e esta frase. A resposta que o seu código recebe fica assim:
{"code":403,"error_code":"bad_jwt","msg":"invalid claim: missing sub claim"}
Pense numa recepção que recebe uma única pergunta: que visitante é este? Você entrega um crachá. A recepção confere primeiro o carimbo, e o carimbo é autêntico. Depois lê a linha do nome, e a linha do nome está vazia. A recepção não tem com o que responder, então recusa, e a recusa aponta a linha vazia.
Qualquer chamada que precise de uma pessoa logada pode receber essa resposta.
getUser() e updateUser() são as duas em que você mais provavelmente vai
vê-la, e adicionar um segundo fator ou vincular outro método de login passa
pela mesma verificação.
Uma chave nomeia o seu app. Um token de sessão nomeia uma pessoa.
A chave de API do seu projeto é o crachá com a linha do nome vazia, e ela foi feita assim de propósito.
A documentação de chaves de API diz isso numa linha: segundo o Supabase, uma chave de API não distingue usuários, e sim aplicações. A chave diz ao Supabase qual app está chamando. Quem está usando esse app é outra pergunta, respondida por um token que o Supabase Auth emite para cada pessoa quando ela faz login.
A referência de JWT do Supabase mostra os dois. A chave anon de um projeto mais
antigo decodifica para isto, e não existe sub em lugar nenhum:
{
"iss": "supabase",
"ref": "abcdefghijklmnopqrst",
"role": "anon",
"iat": 1640991600,
"exp": 1640995200
}
O token de acesso de uma pessoa logada traz o id dela, o papel authenticated e
a sessão a que pertence (cortado aqui para as linhas que importam):
{
"sub": "123e4567-e89b-12d3-a456-426614174000",
"role": "authenticated",
"session_id": "123e4567-e89b-12d3-a456-426614174000",
"email": "user@example.com"
}
A chave service_role é de novo a primeira forma, com "role": "service_role"
e ainda sem sub. As chaves mais novas sb_publishable_ e sb_secret_ nem são
JWTs, mas a
página de soluções do Supabase para este erro
diz que o Auth as reconhece e que elas também não trazem o claim sub. Então
qualquer chave do seu projeto, antiga ou nova, pública ou secreta, produz esta
mensagem quando é entregue ao Auth como se fosse uma pessoa. A mesma página
aponta o erro com todas as letras: usar sem querer uma chave de API do Supabase
no lugar do token de acesso do Auth.
Se chaves e prefixos são novidade para você, quais chaves de API podem ir no seu frontend explica como separar a pública da secreta.
Onde a chave é enviada no lugar de uma sessão
Três caminhos levam até aqui, e o primeiro é o que uma Edge Function percorre.
1. Uma Edge Function chamada de uma página onde ninguém está logado. Quando
o supabase-js tem uma sessão, ele coloca o token da pessoa no cabeçalho
Authorization. Quando não tem, não tem mais nada para pôr ali, então com uma
chave antiga ele manda a chave do seu projeto nesse cabeçalho. O arquivo
fetch.ts dele diz exatamente isso: o token de sessão, se houver, senão a
chave.
A verificação Verify JWT da plataforma deixa essa requisição entrar. Uma chave antiga é um JWT que o seu projeto assinou, e o Supabase documenta que a verificação aceita as chaves novas também: é a brecha que mover um segredo para uma Edge Function explica por inteiro. A sua função então repassa o cabeçalho ao Auth, que é o que estas linhas fazem:
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: req.headers.get('Authorization')! } } },
)
const { data: { user } } = await supabase.auth.getUser()
O Auth lê a chave, encontra a linha do nome vazia e responde 403. Se a função
devolve essa falha, o seu app mostra Edge Function returned a non-2xx status code, com esta mensagem esperando no log da função.
2. Uma chamada feita antes de o login terminar. A página de soluções do
Supabase também aponta este caso: um usuário que começou a se cadastrar ou a
fazer login e não tinha terminado quando getUser foi chamado. Uma forma disso
é um cadastro que ainda espera o e-mail de confirmação. Ainda não há sessão,
então o que chegou ao Auth não era o token de uma pessoa.
3. O seu próprio servidor entrega o valor errado ao Auth. Uma rota que chama
getUser(token) com a chave em token, ou uma automação no n8n, no Make ou no
Postman que chama /auth/v1/user com a chave no cabeçalho Authorization. A
solução que a página do Supabase dá para este caso é garantir que o token
passado para getUser() seja o token de acesso da pessoa.
Como ver o que a sua função recebeu
Compare dois cabeçalhos da requisição no seu navegador.
Abra o seu app publicado, aperte F12, escolha a aba Network e repita o que
falhou. Clique na requisição cujo endereço contém /functions/v1/ e o nome da
sua função, e leia os cabeçalhos da requisição:
AuthorizationtrazBearere depois a mesma sequência queapikey. A página não tinha sessão para enviar, então mandou a chave duas vezes. Você está no caminho 1 ou 2.Authorizationtraz outra sequência longa que começa comeyJ. O token de uma pessoa saiu. A função ou o seu servidor perdeu ele no caminho, então você está no caminho 3.- Não há cabeçalho
Authorization. Então não é esta a mensagem que você vai ver. Com o Verify JWT ligado, a plataforma recusa a requisição com um 401 antes de o seu código rodar, e o post do erro non-2xx lê isso. Desligado, o Auth respondeThis endpoint requires a valid Bearer token, a primeira linha da tabela mais abaixo.
Se a mensagem estiver no console do navegador e disser
Auth session missing!, nenhuma requisição chegou ao Auth: o supabase-js não
achou sessão nenhuma e parou antes de enviar uma, e
o artigo dela cobre esse caso.
Leia os valores onde estão. Não cole nenhum dos dois num site que se oferece para decodificar: a sua chave é pública de qualquer jeito, mas o token de acesso de uma pessoa deixa quem o tiver agir como ela até ele expirar.
A solução: perguntar só quando há alguém logado
Decida o que a função deve fazer com um visitante que não fez login, e escreva essa decisão na função.
Se só pessoas logadas devem usá-la, duas mudanças. A página chama a função
só depois que alguém fez login. E a função responde um 401 claro quando
getUser() não encontra usuário, para que uma chamada perdida vinda de uma
página sem login receba uma recusa clara em vez de uma pane:
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return new Response(JSON.stringify({ error: 'Sign in first' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
Mantenha os cabeçalhos CORS nessa resposta também, senão o navegador a esconde
do seu app e você dá de cara com
"Failed to send a request to the Edge Function"
no lugar. O Supabase também oferece um wrapper, withSupabase({ auth: 'user' }),
que faz a mesma verificação antes de o seu handler rodar; o
post da Edge Function mostra a
configuração.
Se visitantes sem login também devem usá-la, pergunte ao Auth só no caminho que precisa de uma pessoa. Trate uma recusa ali como "isto é um visitante" e siga com o que um visitante pode fazer, em vez de deixar o erro parar a requisição.
No seu próprio servidor, mande o token de acesso da pessoa a partir do
navegador, como a página do Supabase descreve: coloque ele num cabeçalho
Authorization: Bearer da sua requisição, leia no servidor e chame
supabase.auth.getUser(token) com esse valor. A chave do seu projeto nunca
entra nessa chamada.
Duas soluções que pioram tudo
As duas são sugeridas para este erro, e as duas deixam você pior do que o erro deixava.
Trocar pela chave secreta. Não tem como ajudar, porque service_role e
sb_secret_ também não trazem sub, então o Auth responde do mesmo jeito. O
estrago está em onde a chave vai parar. Peça a um builder para "usar a chave de
serviço" e passar por um erro, e ele pode muito bem colocá-la na página que os
seus visitantes baixam, onde ela lê e escreve cada linha do seu banco, não
importa o que as suas regras digam. Se isso já aconteceu,
faça a rotação dela primeiro.
Esse deslize dá para ver de fora. O nosso scan gratuito lê o JavaScript do seu app publicado do jeito que um estranho leria e aponta cada chave que encontra, dizendo se ela deveria estar ali. Leva uns 20 segundos e não precisa de conta: escaneie o seu app.
Apagar a verificação e pegar o id do usuário da requisição. Uma função que
lê userId do corpo da requisição acredita em quem quer que mande. Se essa
função ainda escreve com a chave secreta, qualquer um que abra a sua página e
leia como a função é chamada pode agir como qualquer um dos seus usuários, e
nada nos seus logs diz que não foram eles. Com a verificação no lugar, um
estranho que tenta a mesma chamada recebe este erro e mais nada.
As outras mensagens da mesma verificação
As verificações do Auth rodam numa ordem fixa e cada uma tem a sua frase, então a mensagem diz até onde o token chegou.
| Mensagem | Status e código | O que aconteceu |
|---|---|---|
This endpoint requires a valid Bearer token | 401 no_authorization | Não chegou nenhum cabeçalho Authorization: Bearer. |
invalid JWT: unable to parse or verify signature, … | 403 bad_jwt | O carimbo está errado: um token danificado, um de outro projeto ou uma chave antiga. |
invalid claim: missing sub claim | 403 bad_jwt | O carimbo está certo e a linha do nome, vazia: uma chave de API. |
invalid claim: sub claim must be a UUID | 400 bad_jwt | Há um nome, mas não no formato de um id de usuário do Supabase. |
User from sub claim in JWT does not exist | 403 user_not_found | O token nomeia um usuário que já foi apagado. |
Session from session_id claim in JWT does not exist | 403 session_not_found | O token sobreviveu à sua sessão, por exemplo depois de um logout em outro aparelho. |
Cada linha vem do mesmo arquivo, internal/api/auth.go. A versão que o seu
próprio banco dá para a linha do carimbo errado, PGRST301, tem
um post só dela. A linha da sessão se lê junto com
por que os usuários do Supabase são deslogados.
Manter a verificação depois da próxima mudança
Uma verificação de quem está chamando pode sumir da próxima vez que você pedir ao seu builder para fazer um erro sumir, e os dois jeitos mais rápidos de calar este são os dois aí de cima.
O Reeve Monitor varre o seu app publicado de novo a cada hora:
- as nove verificações a cada hora, em até três apps
- um e-mail no dia em que uma nova varredura avaliar o seu app pior que a anterior; uma chave secreta no seu JavaScript limita sozinha a nota a D
- se o app está no ar, a cada 60 segundos
- um relatório mensal do que ele viu
Uma função que acredita no id de usuário que mandam para ela pode mudar as linhas de qualquer um dos seus usuários para quem a chamar. O Reeve Care guarda uma cópia do seu banco Supabase de antes disso acontecer.
- uma cópia criptografada conforme uma agenda, guardada no armazenamento do próprio Reeve na UE, fora da sua conta Supabase
- cada cópia verificada antes de contar, contando as linhas de cada tabela
- uma restauração em um clique que salva o que está lá agora antes de substituir
- também os seus arquivos enviados, assim que você conectar uma credencial de Storage
- tudo o que o Monitor faz
O que fazer agora
O que fazer
- Abra a requisição que falha na aba Network. Se
Authorizationtraz o mesmo valor queapikey, ninguém estava logado quando ela foi enviada. - Chame funções que precisam de uma pessoa só depois do login, e faça a função responder 401 quando
getUser()não encontrar usuário. - No seu próprio servidor, passe para
getUser()o token de acesso da pessoa. A chave do seu projeto não tem usuário nenhum dentro. - Deixe a chave secreta onde está. Ela também não nomeia ninguém, e na página ela lê e escreve cada linha.
- Mantenha a verificação de quem está chamando. Uma função que pega o id do usuário da requisição acredita em qualquer um.
Se o seu app mostra Edge Function returned a non-2xx status code enquanto esta
mensagem está no log, a função está repassando a recusa, e
esse post lê a resposta a
partir daí. O checklist de segurança de 10 minutos cobre o que mais
um app recém-lançado costuma deixar aberto.
Perguntas frequentes
O que significa "invalid claim: missing sub claim" no Supabase?
Significa que perguntaram ao Supabase Auth qual usuário está chamando e entregaram a ele um token sem usuário nenhum dentro. O token em si era autêntico e passou na verificação de assinatura. O que faltava era o claim sub, o campo que guarda o id de um usuário. É exatamente assim que são as chaves de API do seu projeto, e por isso a causa de costume é uma chave enviada onde deveria ir o token de um usuário logado.
É o mesmo que "Auth session missing!"?
Não. "Auth session missing!" vem do supabase-js no navegador, que não encontra sessão nenhuma e nem envia a requisição. "invalid claim: missing sub claim" volta do Supabase Auth, que recebeu uma requisição e encontrou uma chave onde deveria estar o token do usuário. O primeiro costuma indicar uma página sem login; o segundo, uma função ou um servidor que repassou a chave.
Desligar o Verify JWT resolve?
Não. Verify JWT é uma verificação que o Supabase faz numa Edge Function antes de o seu código começar, e esta mensagem vem depois, do Supabase Auth, quando o seu código pergunta quem está chamando. Desligar a verificação da plataforma deixa chegar ao seu código também as requisições sem token nenhum, e o erro continua exatamente onde estava.
Um login anônimo causa esse erro?
Não. Um login anônimo cria um usuário de verdade com id próprio, então o token dele traz um claim sub e is_anonymous como true. A chave anon é outra coisa, apesar do nome parecido: uma chave de projeto sem usuário dentro, e é ela que produz esta mensagem.
Por que algumas páginas dizem 401 e outras 403?
A página de soluções do Supabase para esta mensagem tem 401 no título. O código atual do Supabase Auth responde 403, com o código de erro bad_jwt. Se o seu código decide o que fazer só pelo status, confira também o código de erro e a mensagem.