API returns password hashes or too many fields: how to fix it
An API that returns password hashes or other private fields sends whole database rows to the client, so change each query to return only the named fields that the client needs. Hiding a field in the frontend does not help. The browser receives the full response, and any user can read it. The fix is on the server.
This guide shows how to find the extra fields, how to stop the leak, and what to do about hashes that already left. It also shows how to find out who read the data.
What does “API returns too much data” mean?
It means that the response has fields that the caller does not need or must not see. Common examples are password_hash, reset_token, stripe_customer_id, is_admin, email of other users, internal notes, and API keys.
OWASP lists this problem as API3:2023, Broken Object Property Level Authorization. The risk is that an API does not check if the user may read each property of an object. OWASP says that attackers find these properties when they inspect responses.
Vibe coding tools create this problem in a simple way. The tool writes one query that reads a row. It then returns the row as JSON. The row has every column in the table. The page only shows the name and the avatar, so nobody sees the other columns. The browser still receives them.
Three code patterns cause most cases:
| Pattern | Example | Result |
|---|---|---|
| Return the whole record | res.json(user) |
All columns leave the server |
| Select all columns | select("*") or an omitted select |
The API returns every column |
| Generic serializer | to_json(), model_dump() with no field list |
Every property of the object appears |
OWASP advises against generic methods such as to_json() and to_string(). It also advises that you keep returned data to the bare minimum for the endpoint.
The same problem appears in Supabase apps. The PostgREST documentation says that the select parameter chooses the columns. If you omit it, the default is *, which means all columns. A user who can read a row can read every column of it, unless column privileges limit the access.
This guide is about fields in a response. A related problem is the reverse: the client sends fields that it must not set. That problem is mass assignment. Another related problem is access to the wrong rows, which is BOLA.
How can I check if my API returns too much data?
Read the raw responses. Do not trust the page, because the page shows only a part of the data.
- Open your app in a browser. Open DevTools and select the Network tab.
- Use the app as a normal user. Open the profile page, the user list, the team page, and the settings page.
- Filter for
fetchorXHRrequests. Select each request and read the Response tab. - Search each response for risky names. Search for
password,hash,token,secret,key,email,role,admin, andstripe. - Compare the fields with the screen. Each field that the page does not show is a field that the API does not need to send.
Also test the API without a browser. A direct request shows what a stranger gets:
curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY"
Use your own project URL and your public anon key. If the response has rows with private columns, the data is public. Replace profiles with each table in your project.
For a custom API, call the list routes and the detail routes:
curl -s https://your-app.example/api/users | head -c 1500
curl -s https://your-app.example/api/users/123 -H "Authorization: Bearer USER_TOKEN"
Check the list route with care. A list route often returns more fields per user than the detail route, and it returns many users at once.
In Supabase, you can also read the columns of a table to see what it holds:
select table_name, column_name
from information_schema.columns
where table_schema = 'public'
and column_name ~* '(pass|hash|token|secret|key)';
The query lists columns with risky names in the public schema. Each result needs a decision: delete it, move it, or restrict it.
Warning: Do not paste a real user’s token or hash into a public tool when you test. Use a test account.
What do I do first? Fix it now, in order of urgency
Do the steps in this order. Steps 1 to 3 stop the leak. The later steps clean up the results.
Caution: If the leaked fields include hashes, tokens, or keys, you must treat them as stolen. Rotate them after you stop the leak. See the next section.
- Stop the response. Change the query or the serializer so that it returns named fields only. Deploy the change. See the code below.
- Remove the fields from the public API. If the data is in Supabase, restrict the columns or move them to a private table. See the SQL below.
- Test the live API. Repeat the
curltests on the production URL. The risky fields must not appear. - Rotate what leaked. Reset passwords, delete tokens, and replace keys.
- Check the logs. See the section on damage below.
- Add a response check. See the section on prevention below.
Return named fields only
Write an allow list. The list names each field that the client may receive. The list is the rule. A new column in the database does not appear in the API until you add it to the list.
In an Express route:
const row = await db.user.findUnique({ where: { id } });
if (!row) return res.sendStatus(404);
const { id: userId, displayName, avatarUrl } = row;
res.json({ id: userId, displayName, avatarUrl });
Better, ask the database for the named columns only. A query that never reads the hash cannot return it. With Prisma, use select:
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, displayName: true, avatarUrl: true },
});
In Supabase, name the columns in the client call:
const { data } = await supabase
.from("profiles")
.select("id, display_name, avatar_url")
.eq("id", userId)
.single();
Do not stop here. A named select in your frontend does not protect the API. A caller can send select=* to the REST endpoint by hand. Restrict the data in the database too.
Restrict columns in Supabase
The best fix is to keep private data out of the exposed table. Make a second table for it. Do not give the API roles any access to the second table, or protect it with RLS that allows only the owner:
create table public.profile_private (
user_id uuid primary key references auth.users (id),
stripe_customer_id text,
internal_notes text
);
alter table public.profile_private enable row level security;
create policy "owner reads own private data" on public.profile_private
for select to authenticated
using (user_id = (select auth.uid()));
Then move the columns from profiles to the new table and drop them from the old one.
If you must keep the columns in the same table, use column privileges. The Supabase column level security page shows the pattern. You revoke the table-wide privilege, then grant the privilege on named columns:
revoke select on table public.profiles from anon, authenticated;
grant select (id, display_name, avatar_url) on table public.profiles to anon, authenticated;
The page gives the same pattern for update. It also warns about side effects. A restricted role cannot use the wildcard * on the table. A query that uses select * fails after the change, so update every client call to name its columns. The page calls this an advanced feature. Test each screen of the app after the change.
Note: Row Level Security and column privileges do different jobs. A policy limits the rows. A column privilege limits the columns. You need the correct policy for both. For the policy side, read the Supabase RLS guide.
Serializer rules for other stacks
The rule is the same for each framework. Use an explicit output model. In FastAPI, set a response_model that has only the public fields. In Rails, pass only: to as_json. In Django REST Framework, list the fields of the serializer. Do not use fields = "__all__". Check the documentation of your framework for the exact option.
What do I do about hashes that already leaked?
Assume that an attacker has copied each hash that the API returned. An attacker does not need to guess the hash. The attacker tests many passwords against it offline, with no limit and no log in your system.
Use these steps:
- Find the affected users. The affected users are the users whose rows the API returned. If any caller could read the whole table, all users are affected.
- Invalidate the passwords. Force a password reset for each affected user. Do not wait for the user to log in.
- End the sessions. Delete the refresh tokens and the session records of the affected users.
- Delete exposed reset tokens and API keys. Create new values. An old reset token can still change a password.
- Tell the users to change reused passwords. An attacker can try the cracked password on other sites.
- Check the hash algorithm. Use a slow, salted algorithm such as bcrypt or Argon2. Do not use plain MD5 or SHA-1. A fast algorithm lets an attacker test guesses much faster.
- Keep the hash column out of the API forever. The best place for a hash is a table or schema that the API roles cannot read.
In Supabase Auth, the password hashes are in the auth schema. The API does not expose that schema by default. If your app copies hashes or passwords to a public table, delete the copy.
If your own code stores passwords, ask yourself why. A managed auth service removes this duty from your code.
How do I check the damage?
Find out if anyone other than your own app read the extra data. The answer decides who you must warn.
- Find the start date. Use the git history to find when the route or the query started to return the fields. Use the deploy history to find when it went live.
- Read the API logs. In your host or in the Supabase dashboard, open the API and Postgres logs for the period. Look for requests to the affected route.
- Look for unusual callers. Look for requests with no browser user agent, many requests from one IP address, and requests that use
select=*. A person who reads a leak with a script often pages through all rows. - Look for the pattern of a dump. A dump shows as many requests with an
offsetor alimitvalue that grows, or as one request with a very large response size. - Check the data in the response. Make a list of every field that the route returned. Mark each field as public, personal, or secret.
- Check other routes. A tool that wrote one leaky route often wrote more. Test each route that returns a user, a team, or an order.
- Check if the data was public. If the route needed no login, any person on the internet could read it. Count that period as public exposure.
If your logs do not keep the response body, you cannot see what the attacker received. Assume the response had all fields that the code could return.
Note: Many hosts keep logs for a short time. Export the logs for the period now, before they expire.
How do I stop this problem in the future?
Do not rely on a person to remember each field. Make the safe path the default.
- Write one output type per resource. Make a
PublicUsertype and aPrivateUsertype. Return only these types. OWASP advises that you use schema-based response validation as an extra layer. - Never return a database row directly. Add a lint rule or a code review check for
res.json(user),return row, andselect("*"). - Add a test with forbidden keys. The test calls each route and fails if the response has a key such as
password,hash,token, orsecret:
const FORBIDDEN = /pass(word)?|hash|token|secret/i;
test("user routes do not leak private fields", async () => {
const res = await api.get("/api/users/123");
const keys = Object.keys(res.body);
expect(keys.filter((k) => FORBIDDEN.test(k))).toEqual([]);
});
- Keep secrets out of exposed schemas. Put hashes, tokens, and keys in a table that API roles cannot read.
- Use column privileges as a second layer. Grant
selecton named columns only for tables that the browser can call. - Tell your coding tool the rule. Add this to the project rules: “Return only named fields. Never return a full database row.”
- Test the live API after each release. A scan of the deployed app calls the routes that the browser uses and reads the real responses.
For a wider view of API design, read the API security guide.
When should I get help?
Get help in these cases:
- The API returned hashes, and you do not know who read them. Ask a security engineer to review the logs.
- The data includes payment, health, or ID information. You may have legal duties to report the problem.
- The API was open to anonymous callers. Treat the data as public, and plan the user notice with care.
- You cannot find the routes that return the data. A scan of the live app lists the responses that the browser receives.
After you finish, run the first curl test again. The response must show only the fields that you named.
Common questions
Is a bcrypt hash safe to expose?
Does hiding the field in the frontend fix the problem?
Do Row Level Security policies hide columns?
Must I force a password reset after the leak?
What is the fastest way to stop the leak?
Find leaky API responses
A VibeEval scan calls the API routes that your app uses and reports response fields that look like hashes, tokens, or private data.
14-day free trial · No credit card · Cancel anytime