Lucis

We skipped a startup's paid subscription by changing one field in Supabase

During an authorized pentest we found an authorization flaw: a regular account could change the attribute that defined its privileges and reach internal functions.

Lucis team · Lucis
The short version: an RLS policy can stop a user from editing someone else’s row and still allow them to change sensitive columns in their own profile. In this case, a regular account could change the attribute that defined its privileges because authorization controlled the row, but not the editable columns.

The context

During an authorized pentest, we reviewed a ticketing platform built with Next.js and Supabase. The goal was to understand what a regular account could do, what data was exposed and which internal operations could be called from the client.

We were not trying to “break” the platform. We wanted clear answers:

What can an authenticated account see outside its organization? Which operations does the browser’s API actually allow? * Can a user change data the system treats as trusted?

The most important answer appeared in the last question.

We started with the browser

We used the application like any other person: registration, sign in, profile and ticket related flows. We reviewed requests, routes, edit parameters and the JavaScript delivered to the browser.

That revealed part of the architecture: Next.js on the frontend and Supabase for authentication, the database and part of the backend logic. The browser used Auth endpoints and the REST API that Supabase generates over PostgreSQL through PostgREST.

The public JavaScript included the project URL and the client’s anon key:

createClient(
  "https://<PROJECT>.supabase.co",
  "<ANON_KEY>"
)

That was not a vulnerability by itself. A project URL and a public key can be part of a normal integration. Security depended on something else: Row Level Security policies, PostgreSQL permissions, the columns each role could edit and the execution permissions of RPC functions.

So we stopped looking only at the interface and started understanding the API.

The API told us more than it seemed to

PostgREST responded differently when we queried a missing table, a valid table with no visible rows or a table with accessible information. The test was controlled, with anonymized names and no enumeration of real data:

GET /rest/v1/<CANDIDATE_TABLE>?select=*
apikey: <ANON_KEY>

In general, we could distinguish between:

404 / PGRST205  -> table missing or not exposed
200 []          -> valid table with no visible rows
200 [{...}]     -> valid table with accessible information

We could also test column names in a bounded way. When a column did not exist, some errors suggested similar names. Step by step, we built a partial map of the application without administrative access.

We will not publish the real table, column or entity names. To explain the issue, it is enough to know that we identified objects related to accounts, listings, ticket types and internal operations. One of those resources exposed commercial information without authentication.

Then the internal functions appeared

Supabase also exposes PostgreSQL functions through PostgREST RPC routes:

POST /rest/v1/rpc/<FUNCTION_NAME>

We tested names found in the frontend and others related to the behavior we had observed. But an error such as PGRST202 does not prove that a function is vulnerable. It may mean that the function does not exist, expects different parameters or has another signature.

We only treated a function as vulnerable when we could execute it with valid data and receive a real response.

One function returned operational information without requiring a signed in user. The simplified and anonymized request looked like this:

POST /rest/v1/rpc/<INTERNAL_FUNCTION>
apikey: <ANON_KEY>
Content-Type: application/json

{
  "identifiers": ["<TEST_ID>"]
}

Without an Authorization header, we received HTTP 200 with structured commercial information. Negative controls using an empty list and a nonexistent UUID returned empty responses. The problem was that the anon role could execute a function that did not need to be available to visitors:

GRANT EXECUTE
ON FUNCTION <INTERNAL_FUNCTION>(...)
TO anon;

Another function could generate internal values

We also found an RPC function that generated codes used by the platform. We called it several times, always within the authorized scope and with test values.

The result was consistent:

every response succeeded; each execution returned a different value; signing in was not required; we saw no limits during the bounded test.

That showed that an anonymous user could invoke the generator. It did not show that those values could be used to obtain a benefit or complete another operation. Proving that would have required validating another flow.

That distinction matters. We have to separate what appears possible from what we can actually demonstrate.

The most serious issue was in the profile

We then created a test account through Supabase Auth and compared what a visitor could do with what an authenticated user could do.

The configuration issued a valid session without requiring prior email confirmation. The registration endpoint also responded differently when an email already existed, which allowed account enumeration. We reported that as an additional weakness, but it was not the main path to privilege escalation.

First, we tried to read other users’ profiles. We could not. Each user could only query their own record. That control worked correctly.

We then changed normal fields in our own profile. That also behaved as expected. So we added an internal attribute related to the account’s access level to the same update request.

The anonymized example was equivalent to:

PATCH /rest/v1/<PROFILE_TABLE>?id=eq.<OUR_UID>
apikey: <ANON_KEY>
Authorization: Bearer <TEST_TOKEN>
Content-Type: application/json

{
  "<INTERNAL_ATTRIBUTE>": "<PRIVILEGED_STATE>"
}

The API returned HTTP 204 No Content. A successful response was not enough: a later validation, trigger or background process could still revert the value.

We queried our profile again with an authenticated GET and requested only the internal attribute we had changed. The change was still there.

A regular account had just obtained a privileged state by editing its own record.

What had failed

The RLS policy controlled which row each user could edit, but not which columns they could change inside that row. The logic was similar to this:

CREATE POLICY "user_updates_own_profile"
ON <PROFILE_TABLE>
FOR UPDATE
USING (auth.uid() = id);

The profile mixed two kinds of information:

data owned by the user and meant to be editable; internal attributes that defined permissions or sensitive states.

The policy stopped one person from changing another person’s profile. But once the change to their own row was authorized, the API also accepted attributes that only the system should control.

This was not an authentication bypass. The user had signed in correctly.

It was an authorization flaw.

We also demonstrated what was not vulnerable

We tested other attributes to understand the boundary. Some changes persisted. Others were accepted initially but showed their previous value when we queried the profile again. That suggested a second layer of protection for some properties, although it was not applied consistently.

We also tried to modify other users’ records and could not confirm that it was possible.

The demonstrated scope was:

privilege escalation on the test account: confirmed; persistence of the privileged state: confirmed; modification of other accounts: not confirmed; downstream impact: validated only as far as the agreed scope allowed; * test account: restored to its original state.

Not everything we tested was vulnerable. The application had controls that worked: restrictions on other users’ data, server side validation, protection for sensitive operations, sign in limits and controls around files and redirects.

A pentest is not about collecting strange responses and calling them vulnerabilities. It is about showing what an attacker can actually do and, with the same clarity, what we could not do.

How to fix it

Hiding the attribute in the frontend does not help. Anyone can manually change a request sent from their browser.

User editable data must be separated from attributes that define permissions, roles or internal states. The API should accept an explicit list of allowed fields and reject anything else.

In PostgreSQL, permissions on the sensitive column can also be revoked while UPDATE is granted only for editable columns:

REVOKE UPDATE (<INTERNAL_ATTRIBUTE>)
ON <PROFILE_TABLE>
FROM authenticated;

GRANT UPDATE (<EDITABLE_FIELDS>)
ON <PROFILE_TABLE>
TO authenticated;

Privilege changes should go through a separate operation that:

requires authentication; verifies that the caller has permission; validates that the transition is legitimate; records the operation; * raises alerts when appropriate.

Internal RPC functions should not be executable by roles that do not need them:

REVOKE EXECUTE
ON FUNCTION <INTERNAL_FUNCTION>(...)
FROM anon, authenticated;

Each function has to be reviewed individually, both for its permissions and for the information it returns and the effects it produces.

What this pentest left us with

The chain started by looking at browser requests, continued by understanding how the API responded and ended by changing the authorization logic of an account.

We did not need to break Next.js, Supabase, PostgREST or PostgreSQL. The system behaved according to the permissions that had been configured. The problem was that those permissions did not correctly represent the business rules.

It can all be reduced to one question:

If a user can update their own profile, can they also change the attribute that defines their privileges?

In this case, the answer was yes.

When a person can decide their own privileges, login can keep working perfectly while authorization is compromised.

The fix was not another visual layer. It was making the boundary explicit: what the user can edit, what the system controls and what evidence remains when someone tries to cross it.

Nos salteamos la suscripción paga de una startup tocando un solo campo en Supabase

Durante un pentest autorizado encontramos un fallo de autorización: una cuenta común podía modificar el atributo que definía sus privilegios y acceder a funciones internas.

Equipo Lucis · Lucis
Respuesta breve: una política RLS puede impedir que un usuario edite filas ajenas y, aun así, permitir que modifique columnas sensibles de su propio perfil. En este caso, una cuenta común podía cambiar el atributo que definía sus privilegios porque la autorización controlaba la fila, pero no las columnas editables.

El contexto

Durante un pentest autorizado revisamos una plataforma de venta de entradas desarrollada con Next.js y Supabase. El objetivo era entender qué podía hacer una cuenta común, qué datos quedaban expuestos y qué operaciones internas podían invocarse desde el cliente.

No buscábamos “romper” la plataforma. Queríamos responder preguntas concretas:

¿Qué puede ver una cuenta autenticada fuera de su organización? ¿Qué operaciones permite realmente la API que usa el navegador? * ¿Puede un usuario modificar datos que el sistema trata como confiables?

La respuesta más importante apareció en el último punto.

Empezamos por el navegador

Usamos la aplicación como cualquier otra persona: registro, inicio de sesión, perfil y recorridos relacionados con entradas. Revisamos las solicitudes, las rutas, los parámetros de edición y el JavaScript que llegaba al navegador.

Así pudimos entender parte de la arquitectura: Next.js en el frontend y Supabase para autenticación, base de datos y parte de la lógica backend. Desde el navegador se utilizaban los endpoints de Auth y la API REST que Supabase genera sobre PostgreSQL mediante PostgREST.

En el JavaScript público aparecían la URL del proyecto y la clave anon del cliente:

createClient(
  "https://<PROYECTO>.supabase.co",
  "<ANON_KEY>"
)

Eso, por sí solo, no era una vulnerabilidad. Una URL de proyecto y una clave pública pueden formar parte de una integración normal. La seguridad dependía de otra cosa: las políticas Row Level Security (RLS), los permisos de PostgreSQL, las columnas editables por rol y los permisos de ejecución de las funciones RPC.

Por eso dejamos de mirar solo la interfaz y empezamos a entender la API.

La API contaba más de lo que parecía

PostgREST respondía de forma diferente cuando consultábamos una tabla inexistente, una tabla válida sin filas visibles o una tabla con información accesible. La prueba se hizo de forma controlada, con nombres anonimizados y sin enumerar datos reales:

GET /rest/v1/<TABLA_CANDIDATA>?select=*
apikey: <ANON_KEY>

En términos generales, podíamos distinguir entre:

404 / PGRST205  -> tabla inexistente o no expuesta
200 []          -> tabla válida sin filas visibles
200 [{...}]     -> tabla válida con información accesible

También podíamos probar nombres de columnas de forma acotada. Cuando una columna no existía, algunos errores sugerían nombres similares. Poco a poco armamos un mapa parcial de la aplicación sin acceso administrativo.

No publicamos los nombres reales de tablas, columnas ni entidades. Para explicar el problema alcanza con saber que identificamos objetos relacionados con cuentas, publicaciones, tipos de entradas y operaciones internas. Uno de esos recursos exponía información comercial sin autenticación.

Después aparecieron las funciones internas

Supabase también expone funciones de PostgreSQL mediante rutas RPC de PostgREST:

POST /rest/v1/rpc/<NOMBRE_DE_FUNCION>

Probamos nombres obtenidos del frontend y otros relacionados con el comportamiento observado. Pero recibir un error como PGRST202 no demuestra que una función sea vulnerable: puede significar que no existe, que espera otros parámetros o que tiene otra firma.

Solo consideramos vulnerable una función cuando pudimos ejecutarla con datos válidos y obtuvimos una respuesta real.

Una función devolvía información operativa sin que el usuario hubiera iniciado sesión. La request, simplificada y anonimizada, era parecida a esta:

POST /rest/v1/rpc/<FUNCION_INTERNA>
apikey: <ANON_KEY>
Content-Type: application/json

{
  "identificadores": ["<ID_DE_PRUEBA>"]
}

Sin enviar un header Authorization, recibimos un HTTP 200 con información comercial estructurada. Hicimos controles negativos con una lista vacía y con un UUID inexistente; ambos devolvían respuestas vacías. El problema era que el rol anon tenía permiso para ejecutar una función que no necesitaba estar disponible para visitantes:

GRANT EXECUTE
ON FUNCTION <FUNCION_INTERNA>(...)
TO anon;

Otra función podía generar valores internos

Encontramos además una función RPC que generaba códigos utilizados por la plataforma. La ejecutamos varias veces, siempre dentro del alcance autorizado y con valores de prueba.

El resultado fue consistente:

las respuestas fueron exitosas; cada ejecución devolvió un valor diferente; no fue necesario iniciar sesión; no observamos límites durante la prueba acotada.

Eso demostraba que un usuario anónimo podía invocar el generador. No demostraba, en cambio, que esos valores pudieran utilizarse para obtener un beneficio o completar otra operación. Para afirmar eso habríamos tenido que validar un flujo adicional.

Esa diferencia importa: hay que separar lo que parece posible de lo que realmente se pudo demostrar.

El problema más grave estaba en el perfil

Después creamos una cuenta de prueba mediante Supabase Auth y comparamos lo que podía hacer un visitante con lo que podía hacer un usuario autenticado.

La configuración entregaba una sesión válida sin exigir la confirmación previa del correo. Además, el endpoint de registro respondía de manera diferente cuando el correo ya existía, lo que permitía enumerar cuentas. Lo reportamos como una debilidad adicional, pero no era el camino principal hacia la escalada.

Primero intentamos leer perfiles ajenos. No pudimos: cada usuario solo podía consultar su propio registro. Ese control funcionaba correctamente.

Después modificamos datos normales de nuestro perfil. También funcionó como esperaba la aplicación. Entonces agregamos a la misma petición un atributo interno relacionado con el nivel de acceso de la cuenta.

El ejemplo anonimizado era equivalente a:

PATCH /rest/v1/<TABLA_DE_PERFILES>?id=eq.<NUESTRO_UID>
apikey: <ANON_KEY>
Authorization: Bearer <TOKEN_DE_PRUEBA>
Content-Type: application/json

{
  "<ATRIBUTO_INTERNO>": "<ESTADO_PRIVILEGIADO>"
}

La API respondió HTTP 204 No Content. Pero una respuesta exitosa no era suficiente: podía existir una validación posterior, un trigger o un proceso que revirtiera el valor.

Volvimos a consultar nuestro perfil con un GET autenticado y pedimos únicamente el atributo interno que habíamos modificado. El cambio seguía ahí.

Una cuenta común acababa de obtener un estado privilegiado modificando directamente su propio registro.

Qué había fallado

La política RLS controlaba qué fila podía editar cada usuario, pero no qué columnas podía modificar dentro de esa fila. La lógica era parecida a esta:

CREATE POLICY "usuario_actualiza_su_perfil"
ON <TABLA_DE_PERFILES>
FOR UPDATE
USING (auth.uid() = id);

El perfil mezclaba dos tipos de información:

datos que pertenecían al usuario y debían ser editables; atributos internos que definían permisos o estados sensibles.

La política evitaba que una persona modificara el perfil de otra. Pero, una vez autorizado el cambio sobre su propio registro, la API aceptaba también atributos que solo debía controlar el sistema.

No era un bypass de autenticación. El usuario había iniciado sesión correctamente.

Era un fallo de autorización.

También demostramos qué no era vulnerable

Probamos otros atributos para entender hasta dónde llegaba el problema. Algunos cambios persistieron. Otros fueron aceptados inicialmente, pero al volver a consultar el perfil conservaban el valor anterior. Eso sugería una segunda capa de protección para ciertas propiedades, aunque no se aplicaba de forma consistente.

También intentamos modificar registros ajenos y no pudimos confirmar que fuera posible.

El alcance demostrado quedó así:

escalada de privilegios sobre la propia cuenta: confirmada; persistencia del estado privilegiado: confirmada; modificación de cuentas ajenas: no confirmada; impacto posterior: validado únicamente hasta donde permitía el alcance acordado; * cuenta de prueba: restaurada a su estado original.

No todo lo que probamos era vulnerable. La aplicación tenía controles que funcionaban: restricciones sobre datos de otros usuarios, validaciones del lado del servidor, protección de operaciones sensibles, límites en el inicio de sesión y controles sobre archivos y redirecciones.

Un pentest no consiste en juntar respuestas raras y llamarlas vulnerabilidades. Consiste en demostrar qué puede hacer realmente un atacante y, con la misma claridad, qué no pudimos hacer.

Cómo se corrige

Ocultar el atributo en el frontend no sirve. Cualquier persona puede modificar manualmente una request enviada desde su navegador.

Los datos editables por el usuario tienen que estar separados de los atributos que definen permisos, roles o estados internos. La API debería aceptar una lista explícita de campos permitidos y rechazar cualquier otro.

En PostgreSQL también se pueden revocar los permisos sobre la columna sensible y conceder UPDATE únicamente sobre las columnas editables:

REVOKE UPDATE (<ATRIBUTO_INTERNO>)
ON <TABLA_DE_PERFILES>
FROM authenticated;

GRANT UPDATE (<CAMPOS_EDITABLES>)
ON <TABLA_DE_PERFILES>
TO authenticated;

Los cambios de privilegios deberían pasar por una operación separada que:

exija autenticación; verifique que quien realiza el cambio tenga permisos; valide que la transición sea legítima; registre la operación; * genere alertas cuando corresponda.

Las funciones RPC internas tampoco deberían poder ejecutarse por roles que no las necesitan:

REVOKE EXECUTE
ON FUNCTION <FUNCION_INTERNA>(...)
FROM anon, authenticated;

Cada función tiene que revisarse individualmente, tanto por sus permisos como por la información que devuelve y los efectos que produce.

Lo que nos dejó este pentest

La cadena empezó mirando requests desde el navegador, siguió entendiendo cómo respondía la API y terminó modificando la lógica de autorización de una cuenta.

No hizo falta romper Next.js, Supabase, PostgREST ni PostgreSQL. El sistema funcionaba de acuerdo con los permisos configurados; el problema era que esos permisos no representaban correctamente las reglas del negocio.

Todo se puede resumir en una pregunta:

Si un usuario puede actualizar su propio perfil, ¿también puede modificar el atributo que define sus privilegios?

En este caso, la respuesta era sí.

Cuando una persona puede decidir sus propios privilegios, el login puede seguir funcionando perfectamente mientras la autorización queda comprometida.

La solución no fue agregar otra capa visual. Fue hacer explícita la frontera: qué puede editar el usuario, qué controla el sistema y qué evidencia queda cuando alguien intenta cruzarla.