CSV export leaks all users: how to find the leak and fix it
If your CSV export returns every user, the export query has no owner filter, or it runs with a key that bypasses Row Level Security (RLS). Disable the endpoint, add the filter on the server, and test with a second account. The export button is not the control. The endpoint behind the button is the control, and anyone can call it.
This guide shows what causes the leak, how to confirm it, and how to fix it in the order of urgency. It also shows how to check who downloaded the data and how to stop the problem in new endpoints.
What does “export endpoint leaks all users” mean?
It means that a signed-in user can download rows that belong to other users. A normal export should return only the rows of the user who asks. A leaking export returns the whole table.
The leak has four common causes:
| Cause | What happens |
|---|---|
| No owner filter | The query is select * from orders, with no where user_id = ... |
| Key that bypasses RLS | The server code uses the service_role key, so the database does not apply policies |
| Filter value from the request | The endpoint reads ?user_id= from the URL. The caller changes the value |
| Missing role check | An admin export route is open to every signed-in user, or to anonymous callers |
Vibe coding tools often create this problem. You ask for an “export to CSV” feature. The tool writes the shortest working code. It selects all rows and turns them into a file. The feature works for one test account, so the missing filter does not show. The leak appears when the second user signs up.
The first cause is a bug in the query. The second cause is a bug in the key choice. In Supabase, the service_role key is for server code only, and it skips RLS. The Supabase RLS documentation states that service_role “bypasses RLS, so keep it server-side.” A request that carries the access token of a signed-in user runs under the policies of that user. A request that carries only the privileged key does not.
The attack is simple. A user of your app, or an outsider with a free account, calls the export URL. The response is a file with the data of your customers. The attacker needs no special tool. A browser or curl is enough.
How do I confirm that the export leaks data?
Use a test with two accounts. This is the only test that gives a clear answer. Create the accounts yourself and do not use real users.
- Create account A and account B.
- Add one row with a unique word to account A. For example, name a record
leak-test-a. - Sign in as account B.
- Use the export feature as account B and download the file.
- Search the file for
leak-test-a.
If the file has the row of account A, the export leaks. If the file has only the rows of account B, the normal path is safe. Continue with the next test.
Test the endpoint directly
The button can hide a leak that the endpoint has. Find the export request in the browser. Open DevTools, select the Network tab, click the export button, and copy the request as curl. Then change the request:
# Normal request from account B
curl -s "https://your-app.example/api/export?format=csv" \
-H "Authorization: Bearer ACCOUNT_B_TOKEN" | head
# Request with no token
curl -s -i "https://your-app.example/api/export?format=csv" | head
# Request with another user id
curl -s "https://your-app.example/api/export?user_id=ACCOUNT_A_ID" \
-H "Authorization: Bearer ACCOUNT_B_TOKEN" | head
Use these results to read the problem:
- The second command returns data. The endpoint has no authentication.
- The third command returns rows of account A. The endpoint trusts the user id from the URL.
- The first command returns rows of other users. The endpoint has no owner filter, or it bypasses RLS.
Warning: Run these tests only against your own app and your own test accounts. Do not paste real tokens into chats or public tickets.
Search the code
grep -rniE "export|download|csv|toCSV|json2csv" src/ app/ supabase/functions/ | head -50
grep -rn "service_role\|SERVICE_ROLE" src/ app/ supabase/functions/
Read each export route. Look at three things: does the code check the user, does the query have an owner filter, and which key does the database client use.
What do I do first? Fix it now, in order of urgency
Caution: Step 1 breaks the export feature on purpose. A broken export costs less than a continued leak.
- Disable the endpoint. Remove the route, return
503from it, or take the function offline. Do this before you start the real fix. If you use a Supabase Edge Function, delete it withsupabase functions delete <name>, or deploy a version that returns an error. - Rotate the keys that the endpoint used. If the export used the
service_rolekey, and you believe that anyone outside your team saw it, replace it. Read what to do about a leaked service_role key. - Find out what the export returned. Go to the damage section before you restore the feature. You need the facts for the decision on user notices.
- Require authentication. The route must reject a call that has no valid session. Read the user from the session on the server. Never read the user from the request body or the URL.
- Filter by the session user on the server. Use the user id from the verified session in the
whereclause. See the next section. - Enforce the same rule in the database with RLS. The app filter is the first layer. RLS is the second layer, and it protects you when a later endpoint forgets the filter.
- Limit the columns. Select the named columns that the user needs. Do not select
*. Never export password hashes, tokens, or internal notes. - Add a role check for admin exports. If the feature is for admins, check the role on the server, from a source that the user cannot edit.
- Restore the endpoint and run the two-account test again.
How do I write a safe export endpoint?
The safe design has three rules. The server decides who the user is. The database applies the owner rule. The file contains only the columns that you name.
Step 1: Turn on RLS and write an owner policy
The Supabase RLS documentation shows the pattern. Enable RLS on the table, then add a policy that matches the row owner to the signed-in user:
alter table public.orders enable row level security;
create policy "users read own orders"
on public.orders
for select
to authenticated
using ( (select auth.uid()) = user_id );
The to authenticated clause stops anonymous callers from reading the table. The (select auth.uid()) form is the pattern from the Supabase docs. If your table uses another column name for the owner, use that name.
For a full tutorial on RLS, see the Supabase RLS guide. This section only gives the part that an export needs.
Caution: Views skip RLS by default. The Supabase docs state that views bypass RLS because the postgres user usually creates them. If your export reads from a view, create the view with security_invoker = true on Postgres 15 or later:
create view public.orders_export
with (security_invoker = true)
as select id, created_at, total from public.orders;
Step 2: Run the export with the user token, not the privileged key
In a Supabase Edge Function, create the client with the Authorization header of the caller. The database then applies the policy of that user:
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
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();
if (!user) return new Response("Unauthorized", { status: 401 });
const { data, error } = await supabase
.from("orders")
.select("id, created_at, total")
.eq("user_id", user.id)
.limit(10000);
if (error) return new Response("Error", { status: 500 });
return new Response(toCsv(data ?? []), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="orders.csv"',
},
});
});
The code has the filter eq("user_id", user.id) and RLS. If one layer fails, the other layer holds. The code also names the columns and sets a row limit.
If you must use the service_role key, for example for an admin report, always add the filter yourself and check the role first. A privileged key gives you no safety net.
Step 3: Protect the file from CSV injection
A cell that starts with =, +, -, or @ can run as a formula when a person opens the file in a spreadsheet program. The OWASP CSV Injection page lists these characters, and also the tab and the carriage return. An attacker can write such a value into a name field in your app. Then an admin exports the data and opens the file.
OWASP gives two mitigations. You can wrap each cell in double quotes. For Excel, you can put a tab character in front of any cell that starts with a risky character, inside the quoted field. OWASP warns that the tab method changes the stored data, and that the quoting methods may fail in Excel after the file is saved and opened again.
A small function for the toCsv step:
const risky = /^[=+\-@\t\r]/;
function cell(value: unknown): string {
let text = String(value ?? "");
if (risky.test(text)) text = "\t" + text;
return `"${text.replaceAll('"', '""')}"`;
}
This function follows the OWASP tab method. Decide if a changed cell is acceptable for your users. For files that a program reads, such as a JSON export, you do not need this step.
How do I check the damage?
Find out if someone used the leak. You need three facts: when the leak started, who called the endpoint, and which rows the response held.
- Find the start date. Check the git history for the commit that added the export route. The leak started at the first deployment of that code.
- Read the request logs. Look at the logs of your host, your API gateway, or your function. In Supabase, the dashboard has Logs for the API and for Edge Functions. Search for the export path.
- List the callers. Note the user id or the IP address of each call. Your own test calls are expected. Calls from other accounts are the main concern.
- Look for unusual patterns. One account that calls the export many times, calls with changed
user_idvalues, and calls from anonymous sessions are all strong signs of abuse. - Check the data in the file. Read the column list. Find out which personal data the export could expose, such as names, email addresses, phone numbers, or payment details.
- Check other routes. The same mistake often exists in more than one place. Search for the other routes that use the same pattern, such as
/api/download,/api/report, and/api/backup.
Note: If your host keeps no request logs, you cannot prove that nobody downloaded the data. Treat the leak as a possible breach and decide on that basis. Turn on the logs now, so that you have records the next time.
If the logs show that an outside person downloaded data of other users, you may have legal duties to tell the users and the authorities. The rules depend on your region and on the type of data. Ask a lawyer.
How do I stop it from coming back?
Use these controls for every new endpoint that returns a list.
- Turn on RLS for every table in an exposed schema. Do it when you create the table. A table without RLS and with the default grants is open to every client.
- Use the user token for user-facing queries. Keep the
service_rolekey for a small set of server jobs. Search the code for the key name in each review. - Take the user from the session. Do not accept
user_id,owner, oraccount_idfrom the client as the filter. - Name the columns. Do not use
select *in an endpoint that returns data to a client. - Set a row limit. A limit reduces the loss if a filter fails.
- Write the two-account test for each list endpoint. Run it in your test suite. The test signs in as user B, requests the data of user A, and expects an empty result or an error. The BOLA pattern page shows why this class of bug is common in generated code.
- Log every export. Write the user id, the time, the row count, and the IP address. Review the log for spikes.
- Tell your coding tool the rule. Add a line to the project rules: “Every query that returns user data must filter by the session user and must run under RLS.”
For a wider look at ownership checks, read authorization patterns.
When should I get help?
Get help in these cases:
- The logs show calls from accounts that you do not know, and the response held data of other users. Ask a security engineer to review the logs with you.
- The export held sensitive data, such as health data, financial data, or government ids. You likely have reporting duties. Ask a lawyer before you write to your users.
- You cannot find all the routes that return lists. A scan of the deployed app calls each route with a second account and shows which ones leak.
- Your host keeps no logs, and you cannot say who called the endpoint. A professional can help you decide how to treat the risk.
- You use a privileged key in many places and you do not know which code needs it. Ask a developer to review each use.
After the fix, repeat the two-account test on the live site. Account B must get only its own rows, and the call without a token must get a 401.
Common questions
Why does my export show all users and not only my own data?
Is it enough to hide the export button for normal users?
Does the service_role key cause this problem?
What is CSV injection?
Do I need to tell my users about the leak?
Find open export routes
A VibeEval scan calls the export and download routes of your deployed app with a second test account and reports the routes that return rows of another user.
14-day free trial · No credit card · Cancel anytime