Skip to content
Back to field notes
APIsJune 6, 20266 min read

PII in API responses: what AI-built apps return that they shouldn't

AI-generated endpoints often return the whole database row, so a single profile call can leak password hashes, internal flags, and other users' emails. This note shows how to read your own responses before you share the URL.

APIsPIIData exposure

The response carries more than the screen shows

Open your app, log in, and watch a profile request in the Network tab. The screen renders a name and an avatar. The JSON behind it can carry the whole row: a password hash, a Stripe customer id, an is_admin flag, an internal note field, a soft-deleted email, and sometimes fields belonging to other users that got joined in. The UI ignores them. The response still ships them to the browser, where anyone can read the raw body.

This is excessive data exposure, the failure OWASP catalogs as API3. It is not a broken login. The caller is authenticated correctly and is allowed to ask the question. The bug is in the answer: the endpoint returns more than the caller needs, and the extra fields are sensitive. PII in API response security is about what comes back in the body, not who is allowed to call it.

Two things are worth separating up front. What is observable is the response body itself: you can read every field your own API returns right now. What is only possible until you check it is the impact, which depends on whether those fields are sensitive and whether the same endpoint answers for records that are not yours.

Why AI-built apps over-fetch by default

The fast path through most AI codegen is to fetch a record and return it. A prompt like "add an endpoint that returns the logged-in user" produces a handler that loads the user row from the ORM and serializes the whole object to JSON. It works on the first try, the profile page renders, and it looks finished. Nobody wrote the line that says which fields are allowed out.

That missing line is a field allowlist, and it is the step generated code skips most reliably.

  • The serializer passes through the full model. Prisma, Drizzle, an ActiveRecord object, a Mongoose document, a Supabase select with no column list. Every column on the row becomes a JSON key, including the ones added later for billing or moderation.
  • Columns get added over time. A password_hash, a reset_token, a role, a flagged_at, an internal_notes field. Each new migration silently widens every endpoint that returns the whole object.
  • List endpoints multiply it. A GET that returns an array of users hands back one full row per user, so other people's emails and phone numbers ride along in a response the UI only uses for display names.
  • The fix and the demo look identical from the browser, because the extra fields are invisible until someone opens the response body.

A PII in API response security check you can run yourself

You can confirm this on your own app with no attack and no special tooling. The point is to read the raw JSON, not the rendered page.

Open DevTools, go to the Network tab, and use your app normally: load the profile page, open a list, view a detail screen. For each XHR or fetch request, click it and read the Response tab. You are looking for any field the UI never displays.

Two extra checks that change the severity. First, log out (or open a private window) and replay the same request without a session. If PII still comes back, the exposure is unauthenticated, which is the worst case; the companion note on unauthenticated API endpoints in AI-built apps covers finding those routes. Second, if the URL contains a record id like /api/users/42, try a neighbouring id you do not own. A response for someone else's record is a separate authorization problem covered in predictable object ids and API enumeration, and it turns one leaked field into a leak of everyone's.

  • Suspicious result: a user or profile response contains password_hash, password, salt, a reset or session token, is_admin or role, internal_notes, or a Stripe or provider customer id. Any hash-looking string (a long base64 or hex blob next to an email) is a red flag.
  • Suspicious result: a list endpoint returns email, phone, or address for every record when the screen only shows names. That is other people's PII traveling to your browser.
  • Safer result: the response contains only the fields the screen actually uses. A profile call returns id, display name, and avatar URL, and nothing you would not put on a public business card.

Fix it with a field allowlist, not a blocklist

The durable fix is to stop returning the model and start returning an explicit shape. Decide the fields that are allowed out and project only those. This is a DTO, a response schema, or a select with a named column list, depending on your stack.

State the limits plainly. A field allowlist controls what each endpoint returns; it does not decide who may call the endpoint or which records they may reach. An allowlisted response that still answers for records the caller does not own is an authorization bug, and one that answers with no session at all is a missing authentication check. Those are separate fixes. Trimming the body is necessary, not sufficient.

  • Define the output explicitly. A serializer or DTO that lists id, name, and avatarUrl returns exactly those three. New columns added to the table later do not leak, because they were never on the allowlist.
  • Project at the query layer when you can. A Supabase or PostgREST select with an explicit column list, a Prisma select clause, or a SQL projection means the sensitive columns never leave the database in the first place.
  • Prefer an allowlist over deleting fields. Removing password_hash from the object is a blocklist: it protects the one field you remembered and fails the moment a new sensitive column is added. Listing the allowed fields fails safe instead.
  • Apply it to list and detail endpoints alike, and re-check after every migration that adds a column.

Authentication is "who is calling." Authorization is "what may this caller touch." Over-fetching is "what comes back." A response can be perfectly trimmed and still be served to the wrong person, so verify all three rather than assuming a clean body means a clean endpoint.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check works from the public surface, so it inspects the API responses an unauthenticated visitor can actually reach. It scans those public-facing responses for recognizable PII patterns: email addresses, phone numbers, and hash-looking strings returned without authentication. If a public endpoint hands back that shape of data, the scan flags it with severity and fix direction, because data reachable with no session is the highest-severity slice of this problem.

What it does not do is sit inside your authenticated session. The scan does not log in, so it cannot read what a profile endpoint returns to a real user, and it cannot tell whether one logged-in user's response includes another user's fields. That over-fetch behind auth is exactly the kind of thing you confirm with the Network-tab check above and, where the logic is non-trivial, a code or manual review of your serializers.

Next step: open the Network tab, read the response body of your three most data-heavy endpoints, and trim any field the UI does not use down to an explicit allowlist. Then run a focused Launch Check against the public URL before sharing it, so any PII reachable without a login shows up in your scan and not in someone else's.

A clean Launch Check means no obvious PII was leaking on the public surface it tested. It does not prove your authenticated endpoints are trimmed, that your DTOs cover every route, or that authorization is correct. Treat a flagged public response as a launch blocker, and treat a clean result as one signal among several.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.