Saltar al contenido

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.

Vlad Tkachenko12 min de lectura
Una credencial colgada de un cordón, con un sello auténtico y la línea del nombre vacía, delante de dos credenciales con nombre y cara.

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.

El sello se comprueba primero, y las dos credenciales pasan. La pregunta que falla es la segunda: de quién es el nombre que lleva.

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.

Sin nadie con sesión iniciada, la página envía la misma clave en las dos cabeceras. Cada comprobación antes de Auth la deja pasar, y solo la pregunta de Auth encuentra vacía la línea del nombre.

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:

  • Authorization lleva Bearer y después la misma cadena que apikey. 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.
  • Authorization lleva otra cadena larga que empieza por eyJ. 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 responde This 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.

MensajeEstado y códigoQué pasó
This endpoint requires a valid Bearer token401 no_authorizationNo llegó ninguna cabecera Authorization: Bearer.
invalid JWT: unable to parse or verify signature, …403 bad_jwtEl sello está mal: un token dañado, uno de otro proyecto o una clave antigua.
invalid claim: missing sub claim403 bad_jwtEl sello está bien y la línea del nombre, vacía: una clave de API.
invalid claim: sub claim must be a UUID400 bad_jwtHay un nombre, pero no tiene la forma de un id de usuario de Supabase.
User from sub claim in JWT does not exist403 user_not_foundEl token nombra a un usuario que ya fue eliminado.
Session from session_id claim in JWT does not exist403 session_not_foundEl 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 Authorization lleva el mismo valor que apikey, 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.

Escrito por

Vlad Tkachenko

Fundador de Reeve

Me paso el día mirando apps creadas con Lovable, Bolt, v0, Cursor y Replit, y la corta lista de errores que aparecen en ellas una y otra vez.

Más sobre el autor

Sigue leyendo

Todos los artículos

Haz una copia de tu base de datos de Supabase antes del próximo cambio

Reeve la guarda cifrada con regularidad y la restaura por ti si algo falla.

Ver copias de Supabase

Comprobación externa automatizada, no una auditoría completa. La ausencia de hallazgos no es garantía de seguridad.