The short version

An RLS policy can stop a user from editing someone else’s row and still let them change sensitive columns in their own profile. Here, 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 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>

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

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. One of those resources exposed commercial information without authentication.

Then the internal functions appeared

Supabase also exposes PostgreSQL functions through RPC routes. An error such as PGRST202 does not prove that a function is vulnerable: it may not exist, or expect different parameters. We only treated a function as vulnerable when we could execute it with valid data and receive a real response.

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 returned empty responses. The anon role could execute a function that did not need to be available to visitors.

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 an anonymous user could invoke the generator. It did not show 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 separate what appears possible from what we can actually demonstrate.

The most serious issue was in the profile

We created a test account 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: that control worked. Then we changed normal fields in our own profile, and added an internal attribute related to the account’s access level to the same request:

PATCH /rest/v1/<PROFILE_TABLE>?id=eq.<OUR_UID>
Authorization: Bearer <TEST_TOKEN>

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

The API returned 204 No Content. A successful response was not enough, so we queried the profile again. 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:

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

The profile mixed data owned by the user with internal attributes that defined permissions. This was not an authentication bypass: the user had signed in correctly. It was an authorization flaw.

What we demonstrated, and what we did not

We tested other attributes to understand the boundary. Some changes persisted. Others were accepted at first but showed their previous value when we queried the profile again: a second layer of protection for some properties, applied inconsistently. We also tried to modify other users’ records and could not confirm that it was possible.

  • Privilege escalation on the test accountConfirmed
  • Persistence of the privileged stateConfirmed
  • Modification of other accountsNot confirmed
  • Downstream impactWithin agreed scope
  • Test accountRestored

Not everything we tested was vulnerable, and 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; 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. User-editable data must be separated from attributes that define permissions, and the API should accept an explicit list of allowed fields:

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

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

Privilege changes should go through a separate operation that requires authentication, verifies permission, validates the transition, records it and raises alerts when appropriate.

What it 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 or PostgreSQL. The system behaved according to the permissions that had been configured. The problem was that those permissions did not represent the business rules.

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, sign-in 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.