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.