Why users can see each other’s data
Users see other users’ data when your server hands back a record just because a request asked for it by its ID. The server checks that someone is signed in, but not that the record is theirs. Security people call this an insecure direct object reference (IDOR). The OWASP API Security Top 10 calls it broken object level authorization (BOLA) and puts it first on its 2023 list.
Two different checks are at work. The first asks who you are. That is signing in, which security people call authentication. The second asks whether you may open this record. That is authorization, and it is the one that goes missing. The OWASP Top 10 for 2025 ranks broken access control first, and says 100% of the applications in its testing had some form of it.
Who runs into this
Anyone whose app has accounts and private data. It is most likely when:
- a page or API has the record’s ID in the address, such as
/invoices/1042or?user_id=17; - the browser reads and writes the database directly, as it does in most Supabase apps;
- people have different rights, such as parents and teens in a family app, or admins and members in a team tool;
- the app shows or hides buttons based on the user’s role.
A quick test: sign in as one user and copy the address of one of their records. Then open a private window, sign in as a second user, and paste that address. If the record appears, your app has this problem.
The four common holes
Most of these leaks come from one of four mistakes.
- Trusting the ID in the request. The invoice page asks the server for invoice 1042. A curious customer changes it to 1041. If the server only checks that someone is signed in, it returns another customer’s invoice. OWASP notes that the ID can sit in the address, in a header or in the body of the request, and can be a number, a UUID or any other string.
- Trusting who the browser says the user is. A request carries
userId: 17orrole: "admin", and the server believes it. Anyone can change what their browser sends: the Next.js docs warn that form data, URL parameters and headers can be easily modified. Supabase warns that users can update their ownraw_user_meta_data, so it is not a place to keep their role. - Hiding the button instead of checking. The “Delete” or “Admin” button is hidden from normal users, but the server still answers when someone sends that request by hand. OWASP’s advice is never to rely on access checks in the browser. The Next.js docs say the same about Server Actions: anyone can call one with a direct POST request, and a login check on the page does not cover the actions defined in it.
- Leaving the database open. A Supabase app puts a publishable key in the browser. That is safe only because Row Level Security (RLS) limits what the key can reach. Supabase says a table without RLS in an exposed schema can be read and written by any role with a grant on it. When you turn RLS on with no rules, nothing comes back. The quick “fix” is a rule written as
using (true), which lets every row through. Supabase says to use that only for data that is meant to be public.
How to fix it
The rule fits in one sentence: on the server, every read and every write checks that this person may touch this record. When no rule says yes, the answer is no. OWASP calls this deny by default.
- Take the user from the session, nothing else. Your login library checks the session cookie or token on the server. Read the user’s ID, account and role from there. Ignore any user ID in the address, the body or a header.
- Put the owner in the query. Ask the database for “note 1042 in this user’s account”, not “note 1042”:
WHERE id = $1 AND account_id = $2. OWASP’s IDOR cheat sheet shows the same idea, looking a project up only among the current user’s projects. A record that isn’t theirs then looks exactly like one that doesn’t exist. - Refuse with 404 or 403, and nothing more. The HTTP standard lets a server answer 404 Not Found when it wants to hide that a forbidden record exists. OWASP says a short message or a status code is all a refusal needs. Never send logs or debugging details back.
- Check writes, not only reads. Changing and deleting need the same check as viewing. A new record takes its owner from the session, not from the request. When you save changes, copy only the fields a user may change, so nobody can set
roleorowner_idby adding it to the request. OWASP calls that mass assignment. - Check the role and the account together. A role says what kind of user someone is, not which records are theirs. In a family app, a parent may see their teen’s records but not another family’s. In a team app, an admin of one team is not an admin of the next. So check both: the role, and that the record belongs to the same family or team. Admin-only pages and APIs check the role in server code too. OWASP lists normal users reaching admin functions as a separate risk.
/*
* Let a person read, change or delete a note only if it is theirs, or if
* they are an admin of the same account (a parent in a family app, an admin
* in a team app). Everyone else gets the same 404, with no details.
*
* `session` is what your login library verified from the cookie or token on
* the server. The user id, account and role come only from there, never from
* the URL, the request body or a header. Keep roles in your own database, or
* in a claim only your server can set, not in data the user can edit.
*
* The account goes into the query itself, so another account's note looks
* exactly like a note that doesn't exist:
* SELECT id, account_id, owner_id, text FROM notes
* WHERE id = $1 AND account_id = $2;
*
* If the browser talks to the database directly (Supabase), turn on row
* level security so the database applies a rule too. For notes only their
* owner may touch:
* alter table notes enable row level security;
* create policy "owner reads" on notes for select to authenticated
* using ((select auth.uid()) = owner_id);
* plus one policy each for insert (with check), update (using and with
* check) and delete (using). Never `using (true)` on private data.
*/
export type Session = { userId: string; accountId: string; role: 'admin' | 'member' }
export type Note = { id: string; accountId: string; ownerId: string; text: string }
export type Reply = { status: number; body?: unknown }
export interface NoteStore {
/** The note with this id, only if it belongs to this account. */
findInAccount(id: string, accountId: string): Promise<Note | undefined>
/** Saves a new note and returns its id. */
insert(note: Omit<Note, 'id'>): Promise<string>
setText(id: string, text: string): Promise<void>
remove(id: string): Promise<void>
}
const NOT_FOUND: Reply = { status: 404, body: { error: 'Not found' } }
/** The note, but only if this person may touch it. */
async function loadAllowed(store: NoteStore, session: Session, id: string): Promise<Note | undefined> {
const note = await store.findInAccount(id, session.accountId)
if (!note) return undefined
return note.ownerId === session.userId || session.role === 'admin' ? note : undefined
}
export async function createNote(store: NoteStore, session: Session, body: Record<string, unknown>): Promise<Reply> {
if (typeof body.text !== 'string') return { status: 400, body: { error: 'text must be a string' } }
// Owner and account come from the session. An ownerId in the body is ignored.
const id = await store.insert({ accountId: session.accountId, ownerId: session.userId, text: body.text })
return { status: 201, body: { id, text: body.text } }
}
export async function readNote(store: NoteStore, session: Session, id: string): Promise<Reply> {
const note = await loadAllowed(store, session, id)
if (!note) return NOT_FOUND
return { status: 200, body: { id: note.id, text: note.text } } // only what the screen needs
}
export async function updateNote(store: NoteStore, session: Session, id: string, body: Record<string, unknown>): Promise<Reply> {
const note = await loadAllowed(store, session, id)
if (!note) return NOT_FOUND
if (typeof body.text !== 'string') return { status: 400, body: { error: 'text must be a string' } }
await store.setText(note.id, body.text) // the one field a user may change
return { status: 200, body: { id: note.id, text: body.text } }
}
export async function deleteNote(store: NoteStore, session: Session, id: string): Promise<Reply> {
const note = await loadAllowed(store, session, id)
if (!note) return NOT_FOUND
await store.remove(note.id)
return { status: 204 }
}Every function in the example loads the note through loadAllowed. It looks the note up only inside the user’s account, then lets through the note’s owner or an admin of that account. Everyone else gets the same 404. The tests sign in as users from two different families and check that the second family can’t read, change or delete the first family’s note, even as an admin.
If the browser talks to your database directly, as it does with Supabase, Row Level Security is your access check. It needs the same care:
- Turn RLS on for every table in an exposed schema. With RLS on and no policy, PostgreSQL shows no rows at all, which is the safe starting point.
- Write one policy for each action: select, insert, update and delete. Each compares the row’s owner with
(select auth.uid()), the signed-in user. An update policy needs both parts:usingdecides which rows can be changed, andwith checkchecks the new values. The comment at the top of the example has the select policy. - Remove any
using (true)policy on private data. - Keep the secret key, called
service_rolein older projects, on your server. Supabase says it bypasses every RLS policy and must never go in a browser, a shipped app or source control. - Views skip RLS by default, because they are usually created by the
postgresuser. On Postgres 15 and later, create them withsecurity_invoker = trueso they follow the table’s policies. - Test as a real signed-in user. In PostgreSQL the table’s owner normally skips these policies and superusers always do, so a query that works for you as the owner proves nothing.
Why it matters for AI-written code
Ask a coding agent for “a page that shows an invoice by its ID” and you can get exactly that: a login check and a lookup by ID. Who may see which invoice is a rule about your business, and it is rarely in the prompt. Unless you ask for the check, it can be missing.
The gap is also easy to miss when you test by hand. You sign in with your own account, your own data shows up, and that is correct. The leak only shows with a second account, so test with two.
Row Level Security brings its own trap. Once RLS is on, lists stay empty until the policies are written. The fastest change that makes the data show up again is a using (true) policy, or turning RLS off. Both make the app work and remove the protection, so check for both whenever an agent fixes an empty list. Weak RLS in AI-built apps has been reported before: a 2025 entry in the US National Vulnerability Database, CVE-2025-48757, describes sites generated by the Lovable app builder whose RLS policies were not strict enough, so people who weren’t signed in could read or write their database tables. Lovable disputes the entry, saying each customer is responsible for protecting their own app’s data.
Alternatives and how they compare
- Random IDs instead of 1, 2, 3. A UUID is hard to guess, and OWASP suggests random IDs. But IDs leak through shared links, emails and screenshots. OWASP’s IDOR cheat sheet calls hard-to-guess IDs an extra layer, not a replacement for the check. Use them as well as the check, never instead of it.
- Matching the user ID in the address with the session. Checking that
/users/17/orders/…belongs to the signed-in user 17 protects only that one ID. OWASP says this covers only a small subset of cases: the same request can still name an order that belongs to someone else. Check the record itself. - A login check in middleware or a gateway. Worth having, because it turns away people who aren’t signed in before they reach your code. It can’t know whether invoice 1042 belongs to this user, so the record check still has to sit next to the data.
- Checks in your server code, rules in the database, or both. If the browser never talks to the database, the checks in your server code protect the data. If it does, as with Supabase, RLS protects it, because those requests never pass through your code. RLS does nothing for queries made with the secret key, which bypasses it, so server code that uses that key needs its own checks.
- Roles alone. “Admin” and “member” are simple to start with, but a role can’t say which family or team a record belongs to. OWASP’s authorization cheat sheet prefers rules based on attributes and relationships, such as owner, family or team, for most apps, partly because they handle many separate customers better. In practice, check the role and the relationship together, as the example does.
How to use it in your app
- Make two test users in two different accounts. If your app has roles, add one user for each role.
- List every page, API route, server action and background job that reads or changes user data, and every table the browser can reach.
- For each one, write down the check it runs today. “Signed in” alone is not enough for private data.
- Fix each one the way the example does: the user from the session, the account in the query, the same 404 for anything else, and only allowed fields copied on save.
- If you use Supabase, check that every table in an exposed schema has RLS on, with one policy per action and no
using (true)on private data. - Test as the second user. Open the first user’s records by their address, and resend requests with a changed ID from your browser’s developer tools. Then keep a two-user test that runs on every change. OWASP’s advice is to write tests for your access checks and not to ship changes that make them fail.
What to tell your coding agent. Paste this into Claude Code, Codex or Antigravity:
Check this project for places where one user can read or change another user’s data. List every page, API route, server action, background job and database table that handles user data. For each one: take the user’s ID, account and role only from the verified session on the server, never from the URL, the request body, a header or local storage; load records with the owner or account in the query itself (for example WHERE id = $1 AND account_id = $2) and return 404 with no details when nothing matches; apply the same check to update and delete, set the owner of new records from the session, and copy only the fields a user may change. Where there are roles, check the role and that the record belongs to the same account. If we use Supabase, turn on Row Level Security for every table in an exposed schema, write one policy per action using (select auth.uid()), remove any using (true) policy on private data, and keep the secret key out of the browser. Then write tests with two users, A and B, in different accounts, where B tries to read, update and delete A’s records and must get 404. Show me the test output.
To make it a standing rule instead of a one-off request, put the same text in the instructions file your agent reads at the start of a session: CLAUDE.md for Claude Code, AGENTS.md for Codex, and AGENTS.md, GEMINI.md or a file in .agents/rules/ for Antigravity. ArcLab doesn’t connect to any of these tools; you paste the text yourself.
Learn it in depth
This guide covers the checks. The “Make sure users only see their own data” module in the Security Engineering track starts from an example incident, an invoices page where changing the number in the address showed other companies’ invoices. It has you choose a fix for a Supabase app whose tables were opened up with using (true), and ends with an access control matrix you fill in for your own app: every way data can be read or changed, who may do it, and a test that tries to break each rule.
This guide fixes one problem. ArcLab covers 35.
$29 one-time for all 35 modules, plus the ones we add. Or start free: check your own app in about 15 minutes. 14-day refund, as long as you haven’t completed more than one module.
Sources
The official documentation this guide was checked against:
- API1:2023 Broken Object Level Authorization, OWASP API Security Top 10: first on the list; IDs in path, query, header or body; matching the session user ID is not enough; random IDs; tests
- API3:2023 Broken Object Property Level Authorization, OWASP API Security Top 10: mass assignment; allow only the fields a client should change
- API5:2023 Broken Function Level Authorization, OWASP API Security Top 10: normal users reaching admin functions; deny by default, grant to roles
- A01:2025 Broken Access Control, OWASP Top 10:2025: ranked first; 100% of tested applications; deny by default; enforce record ownership
- Authorization Cheat Sheet, OWASP Cheat Sheet Series: deny by default; never rely on browser-side checks; short refusals; attribute and relationship rules over roles
- Insecure Direct Object Reference Prevention Cheat Sheet, OWASP Cheat Sheet Series: check every object; hard-to-guess IDs are an extra layer; queries scoped to the current user
- RFC 9110 HTTP Semantics, section 15.5.4: 403 Forbidden, IETF: a server may answer 404 to hide that a forbidden resource exists
- How to think about data security in Next.js, Next.js docs: client input can be modified; Server Actions are reachable by direct POST; page checks don’t cover actions
- Row Level Security, Supabase docs: RLS on every exposed table; per-action policies with (select auth.uid()); using (true); user metadata; views
- API keys, Supabase docs: the publishable key reaches only what RLS allows; the secret key bypasses RLS and stays out of the browser
- Row Security Policies, PostgreSQL 18 docs: default deny with no policy; table owners and superusers bypass RLS
- CREATE POLICY, PostgreSQL 18 docs: USING checks existing rows, WITH CHECK checks new values
- CVE-2025-48757, NIST National Vulnerability Database: weak RLS in sites generated by Lovable; disputed by the supplier
- How Claude remembers your project, Claude Code docs, Anthropic: CLAUDE.md is read at the start of every session
- AGENTS.md, Codex docs, OpenAI: Codex reads AGENTS.md before doing any work
- Rules, Google Antigravity docs: workspace rules in AGENTS.md, GEMINI.md or .agents/rules/
Last checked 23 October 2026 against the sources above.
Found a mistake? Tell us.