Fundamentos de seguridad
Supabase "invalid claim: missing sub claim": no hay nadie conectado
"invalid claim: missing sub claim": Supabase Auth recibió un token auténtico sin ningún usuario, la clave del proyecto en lugar de una sesión.

En resumen
- "invalid claim: missing sub claim" es Supabase Auth respondiendo 403 con el código bad_jwt. Le preguntaron quién llama, el token que recibió era auténtico y no nombraba a nadie.
- La propia página de soluciones de Supabase nombra la causa común: una clave de API enviada donde va el token de acceso de un usuario con sesión iniciada. Una clave dice qué app llama y no lleva a ninguna persona dentro.
- Llama a la función solo cuando alguien haya iniciado sesión, y haz que responda 401 cuando no haya nadie. Poner la clave secreta en su lugar no cambia nada, porque esa clave tampoco nombra a nadie.
Tu app tiene una Supabase Edge Function que hace algo por la persona que la usa, como guardar sus ajustes o llamar a OpenAI en su nombre. Mientras la probabas, funcionaba. Luego el registro de la función empieza a llenarse con esta línea, y la app muestra un error o nada en absoluto:
AuthApiError: invalid claim: missing sub claim
Busca "invalid claim: missing sub claim" y te dirán que regeneres tus claves,
que desactives Verify JWT o que pongas la clave service_role. Cada uno de esos
consejos trata el token como si estuviera roto. El token estaba bien.
Supabase comprobó su firma y lo aceptó. Lo que le faltaba era un nombre,
cualquiera.
¿Qué significa "invalid claim: missing sub claim"?
A Supabase Auth le preguntaron qué usuario llama; encontró el token auténtico y ningún usuario escrito en él.
Los tokens que lee Supabase Auth son JWT: tres bloques de texto separados por
puntos, donde el bloque del medio son datos legibles. Cada elemento de esos
datos se llama claim. sub, abreviatura de subject, es el claim que guarda el
id de un usuario, y la
referencia de JWT de Supabase
lo describe como el ID del usuario, un UUID.
El mensaje está escrito en el propio código fuente de Supabase Auth, en una
función llamada maybeLoadUserOrSession dentro de internal/api/auth.go.
Cuando esa función se ejecuta, la firma del token ya se comprobó y pasó.
Entonces busca sub, y cuando el claim está vacío se detiene con un HTTP 403,
el código de error bad_jwt y esta frase. La respuesta que recibe tu código se
ve así:
{"code":403,"error_code":"bad_jwt","msg":"invalid claim: missing sub claim"}
Imagina una recepción a la que se le hace una sola pregunta: ¿qué visitante es este? Entregas una credencial. La recepción revisa primero el sello, y el sello es auténtico. Luego lee la línea del nombre, y la línea del nombre está vacía. La recepción no tiene con qué responder, así que se niega, y la negativa señala la línea vacía.
Cualquier llamada que necesite a una persona con sesión iniciada puede recibir
esta respuesta. getUser() y updateUser() son las dos en las que más
probablemente la verás, y añadir un segundo factor o vincular otro método de
inicio de sesión pasa por la misma comprobación.
Una clave nombra tu app. Un token de sesión nombra a una persona.
La clave de API de tu proyecto es la credencial con la línea del nombre vacía, y se hizo así a propósito.
La documentación de claves de API lo dice en una línea: según Supabase, una clave de API no distingue entre usuarios, sino entre aplicaciones. La clave le dice a Supabase qué app llama. Quién está usando esa app es otra pregunta, y la responde un token que Supabase Auth emite a cada persona cuando inicia sesión.
La referencia de JWT de Supabase imprime los dos. La clave anon de un proyecto
más antiguo se decodifica así, y no hay ningún sub en ella:
{
"iss": "supabase",
"ref": "abcdefghijklmnopqrst",
"role": "anon",
"iat": 1640991600,
"exp": 1640995200
}
El token de acceso de una persona con sesión iniciada lleva su id, el rol
authenticated y la sesión a la que pertenece (recortado aquí a las líneas que
importan):
{
"sub": "123e4567-e89b-12d3-a456-426614174000",
"role": "authenticated",
"session_id": "123e4567-e89b-12d3-a456-426614174000",
"email": "user@example.com"
}
La clave service_role es otra vez la primera forma, con
"role": "service_role" y tampoco ningún sub. Las claves más nuevas
sb_publishable_ y sb_secret_ ni siquiera son JWT, pero la
página de soluciones de Supabase para este error
dice que Auth las reconoce y que tampoco llevan el claim sub. Así que cualquier
clave de tu proyecto, antigua o nueva, pública o secreta, produce este mensaje
cuando se le entrega a Auth como si fuera una persona. Esa misma página nombra
el error tal cual: usar sin querer una clave de API de Supabase en lugar del
token de acceso de Auth.
Si las claves y sus prefijos son nuevos para ti, qué claves de API pueden ir en tu frontend explica cómo distinguir la pública de la secreta.
Dónde se envía la clave en lugar de una sesión
Tres caminos llevan hasta aquí, y el primero es el que toma una Edge Function.
1. Una Edge Function llamada desde una página sin nadie con sesión iniciada.
Cuando supabase-js tiene una sesión, pone el token de la persona en la cabecera
Authorization. Cuando no la tiene, no tiene otra cosa que poner ahí, así que
con una clave antigua envía en esa cabecera la clave de tu proyecto. Su archivo
fetch.ts se lee exactamente así: el token de sesión si lo hay, si no, la
clave.
La comprobación Verify JWT de la plataforma deja pasar esa petición. Una clave antigua es un JWT que firmó tu proyecto, y Supabase documenta que la comprobación también acepta las claves nuevas: es el hueco que mover un secreto a una Edge Function explica completo. Después tu función pasa la cabecera a Auth, que es lo que hacen estas líneas:
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()
Auth lee la clave, encuentra la línea del nombre vacía y responde 403. Si la
función devuelve ese fallo, tu app muestra Edge Function returned a non-2xx status code, con este mensaje esperando en el registro de la función.
2. Una llamada hecha antes de que terminara el inicio de sesión. La página
de soluciones de Supabase también nombra este caso: un usuario que había
empezado a registrarse o a iniciar sesión y no lo había completado cuando se
llamó a getUser. Una forma que toma es un registro que todavía espera su
correo de confirmación. Aún no hay sesión, así que lo que llegó a Auth no era el
token de una persona.
3. Tu propio servidor le pasa a Auth el valor equivocado. Una ruta que llama
a getUser(token) con la clave en token, o una automatización en n8n, Make o
Postman que llama a /auth/v1/user con la clave en su cabecera
Authorization. La solución que da la página de Supabase para este caso es
asegurarse de que el token que se pasa a getUser() sea el token de acceso de
la persona.
Cómo ver qué recibió tu función
Compara dos cabeceras de la petición en tu navegador.
Abre tu app publicada, pulsa F12, elige la pestaña Network y repite lo que
falló. Haz clic en la petición cuya dirección contiene /functions/v1/ y el
nombre de tu función, y lee sus cabeceras de petición:
AuthorizationllevaBearery después la misma cadena queapikey. La página no tenía sesión que enviar, así que envió la clave dos veces. Estás en el camino 1 o 2.Authorizationlleva otra cadena larga que empieza poreyJ. Salió el token de una persona. La función o tu servidor lo perdió por el camino, así que estás en el camino 3.- No hay cabecera
Authorization. Entonces no es este el mensaje que vas a ver. Con Verify JWT activado, la plataforma rechaza la petición con un 401 antes de que corra tu código, y el artículo del error non-2xx lo lee. Desactivado, Auth respondeThis endpoint requires a valid Bearer token, la primera fila de la tabla de más abajo.
Si el mensaje está en cambio en la consola de tu navegador y dice
Auth session missing!, ninguna petición llegó a Auth: supabase-js no encontró
ninguna sesión y se detuvo antes de enviar una, y eso lo cubre
su propio artículo.
Lee los valores donde están. No pegues ninguno de los dos en una web que ofrezca decodificarlo: tu clave es pública de todos modos, pero el token de acceso de una persona permite a quien lo tenga actuar como esa persona hasta que caduque.
La solución: preguntar solo cuando hay alguien con sesión iniciada
Decide qué debe hacer la función con un visitante que no ha iniciado sesión, y escribe esa decisión en la función.
Si solo deben usarla personas con sesión iniciada, dos cambios. La página
llama a la función solo cuando alguien ha iniciado sesión. Y la función responde
un 401 claro cuando getUser() no encuentra ningún usuario, para que una
llamada suelta desde una página sin sesión reciba una negativa clara en vez de
un fallo:
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' },
})
}
Mantén también las cabeceras CORS en esa respuesta, o el navegador se la oculta
a tu app y te encuentras con
"Failed to send a request to the Edge Function"
en su lugar. Supabase también ofrece un wrapper, withSupabase({ auth: 'user' }),
que hace la misma comprobación antes de que corra tu handler; el
artículo de la Edge Function muestra
cómo montarlo.
Si también deben usarla visitantes sin sesión, pregunta a Auth solo en el camino que necesita a una persona. Trata una negativa ahí como "esto es un visitante" y sigue con lo que un visitante puede hacer, en vez de dejar que el error detenga la petición.
En tu propio servidor, envía el token de acceso de la persona desde el
navegador, como describe la página de Supabase: ponlo en una cabecera
Authorization: Bearer de tu petición, léelo en el servidor y llama a
supabase.auth.getUser(token) con ese valor. La clave de tu proyecto nunca
entra en esa llamada.
Dos arreglos que lo empeoran
Los dos se sugieren para este error, y los dos te dejan peor que el propio error.
Poner la clave secreta en su lugar. No puede ayudar, porque service_role y
sb_secret_ tampoco llevan sub, así que Auth responde igual. El daño está en
dónde acaba la clave. Pídele a un builder que "use la clave de servicio" para
saltarse un error y puede que la ponga en la página que descargan tus
visitantes, donde lee y escribe cada fila de tu base de datos digan lo que digan
tus reglas. Si eso ya pasó, rótala primero.
Ese desliz se ve desde fuera. Nuestro escaneo gratuito lee el JavaScript de tu app publicada como lo haría un extraño y nombra cada clave que encuentra, incluido si es una que debe estar ahí. Tarda unos 20 segundos y no necesita cuenta: escanea tu app.
Quitar la comprobación y tomar el id del usuario de la petición. Una función
que lee userId del cuerpo de la petición cree a quien lo envíe. Si además esa
función escribe con la clave secreta, cualquiera que abra tu página y lea cómo se
llama a la función puede actuar como cualquiera de tus usuarios, y nada en tus
registros dice que no fueron ellos. Con la comprobación en su sitio, un extraño
que intente la misma llamada recibe este error y nada más.
Los otros mensajes de la misma comprobación
Las comprobaciones de Auth corren en un orden fijo y cada una tiene su propia frase, así que el mensaje te dice hasta dónde llegó el token.
| Mensaje | Estado y código | Qué pasó |
|---|---|---|
This endpoint requires a valid Bearer token | 401 no_authorization | No llegó ninguna cabecera Authorization: Bearer. |
invalid JWT: unable to parse or verify signature, … | 403 bad_jwt | El sello está mal: un token dañado, uno de otro proyecto o una clave antigua. |
invalid claim: missing sub claim | 403 bad_jwt | El sello está bien y la línea del nombre, vacía: una clave de API. |
invalid claim: sub claim must be a UUID | 400 bad_jwt | Hay un nombre, pero no tiene la forma de un id de usuario de Supabase. |
User from sub claim in JWT does not exist | 403 user_not_found | El token nombra a un usuario que ya fue eliminado. |
Session from session_id claim in JWT does not exist | 403 session_not_found | El token sobrevivió a su sesión, por ejemplo tras cerrar sesión en otro dispositivo. |
Cada fila sale del mismo archivo, internal/api/auth.go. La versión que da tu
propia base de datos para la fila del sello equivocado, PGRST301, tiene
su propio artículo. La fila de la sesión se lee
junto con por qué se cierra la sesión de los usuarios de Supabase.
Que la comprobación siga ahí tras el próximo cambio
Una comprobación de quién llama puede desaparecer la próxima vez que le pidas a tu builder que haga desaparecer un error, y las dos formas más rápidas de silenciar este son las dos de arriba.
Reeve Monitor vuelve a escanear tu app publicada cada hora:
- las nueve comprobaciones cada hora, en hasta tres apps
- un correo el día en que un nuevo escaneo califica tu app peor que el anterior; una clave secreta en tu JavaScript limita por sí sola la nota a D
- si la app está disponible, cada 60 segundos
- un informe mensual de lo que vio
Una función que se cree el id de usuario que le envían puede cambiar las filas de cualquiera de tus usuarios para quien la llame. Reeve Care guarda una copia de tu base de datos de Supabase de antes de que eso pase.
- una copia cifrada según un calendario, guardada en el almacenamiento propio de Reeve en la UE, fuera de tu cuenta de Supabase
- cada copia verificada antes de contar, contando las filas de cada tabla
- una restauración en un clic que guarda lo que hay ahora antes de reemplazarlo
- también tus archivos subidos, en cuanto conectes una credencial de Storage
- todo lo que hace Monitor
Qué hacer ahora
Qué hacer
- Abre la petición que falla en la pestaña Network. Si
Authorizationlleva el mismo valor queapikey, no había nadie con sesión iniciada cuando se envió. - Llama a las funciones que necesitan a una persona solo después del inicio de sesión, y haz que la función responda 401 cuando
getUser()no encuentre ningún usuario. - En tu propio servidor, pasa a
getUser()el token de acceso de la persona. La clave de tu proyecto no lleva ningún usuario dentro. - Deja la clave secreta donde está. Tampoco nombra a nadie, y en la página lee y escribe cada fila.
- Mantén la comprobación de quién llama. Una función que toma el id del usuario de la petición se cree a cualquiera.
Si tu app muestra Edge Function returned a non-2xx status code mientras este
mensaje está en el registro, la función está pasando la negativa, y
ese artículo lee la
respuesta desde ahí. La lista de seguridad de 10 minutos cubre lo
demás que una app recién lanzada suele dejar abierto.
Preguntas frecuentes
¿Qué significa "invalid claim: missing sub claim" en Supabase?
Significa que a Supabase Auth le preguntaron qué usuario llama y le entregaron un token sin ningún usuario dentro. El token en sí era auténtico y pasó la comprobación de firma. Lo que le faltaba era el claim sub, el campo que guarda el id de un usuario. Así son exactamente las claves de API de tu proyecto, y por eso la causa habitual es una clave enviada donde va el token de un usuario con sesión iniciada.
¿Es lo mismo que "Auth session missing!"?
No. "Auth session missing!" viene de supabase-js en el navegador, que no encuentra ninguna sesión y ni siquiera envía la petición. "invalid claim: missing sub claim" lo devuelve Supabase Auth, que recibió una petición y encontró una clave donde debía estar el token del usuario. El primero suele indicar una página sin sesión; el segundo, una función o un servidor que pasó la clave.
¿Se arregla desactivando Verify JWT?
No. Verify JWT es una comprobación que Supabase hace en una Edge Function antes de que arranque tu código, y este mensaje llega después, de Supabase Auth, cuando tu código pregunta quién llama. Desactivar la comprobación de la plataforma deja llegar a tu código también las peticiones sin ningún token, y el error sigue exactamente donde estaba.
¿Un inicio de sesión anónimo provoca este error?
No. Un inicio de sesión anónimo crea un usuario real con su propio id, así que su token lleva un claim sub e is_anonymous en true. La clave anon es otra cosa aunque se llame parecido: una clave de proyecto sin usuario dentro, y es la que produce este mensaje.
¿Por qué unas páginas dicen 401 y otras 403?
La página de soluciones de Supabase para este mensaje lleva 401 en el título. El código actual de Supabase Auth responde 403, con el código de error bad_jwt. Si tu código decide qué hacer solo por el estado, comprueba también el código de error y el mensaje.