You asked the model for a route that returns the current user. It wrote `return user`. That one line is both sides of the bug: every property on the row flows out on GET, and every property on the row flows in on PATCH. Attackers read the fields they shouldn't see and write the fields they shouldn't touch.
API3 covers two sides of the same mistake: exposing properties the caller shouldn't see, and letting them write properties they shouldn't touch. The database row and the API contract are not the same thing. Bugs happen when developers treat them as if they are.
What your AI actually built
The model spread the user record into the response. Email, name, avatar — but also passwordHash, stripeCustomerId, internal notes, isAdmin, role, and an internal twoFactorSecret field somebody added last week. The API sends all of it, because nothing filters.
On the write side, the update route does `db.user.update({ where: { id }, data: req.body })`. Whatever the client POSTs lands in the database. Send `{ role: "admin" }` in the body and congratulations, you are one.
Both halves come from the same root cause: the API treats the database row as the API contract. There is no separate shape for 'what this caller is allowed to read' or 'what this caller is allowed to write.'
How it gets exploited
A normal user signs up, logs in, and loads their profile page with the dev tools open.
- 1Read the leakThe /api/me response has 40 fields. Most are boring. Three are not: role, stripeCustomerId, and passwordResetToken. The attacker copies them.
- 2Turn the leak into a writeThey notice the app also has PATCH /api/me for updating display name and avatar. They send the same endpoint with { "role": "admin" } in the JSON body. The response comes back 200 OK with role: "admin".
- 3Walk through the front doorThey refresh the page. The admin panel renders because the frontend trusts the role field. Every admin-only endpoint now returns real data because the backend also trusts it.
The attacker escalated from signup to admin in three HTTP requests, and never touched a single exploit. The root cause is a spread operator the model wrote because the prompt said 'update the user.'
Vulnerable vs Fixed
// app/api/me/route.ts
export async function GET(req) {
const session = await getSession(req);
const user = await db.user.findUnique({ where: { id: session.userId } });
return Response.json(user); // every column, including role and secrets
}
export async function PATCH(req) {
const session = await getSession(req);
const body = await req.json();
const updated = await db.user.update({
where: { id: session.userId },
data: body, // attacker sends { role: 'admin' }, server obeys
});
return Response.json(updated);
}// app/api/me/route.ts
import { z } from 'zod';
const PublicUser = z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
avatarUrl: z.string().nullable(),
});
const UpdateUser = z.object({
name: z.string().max(120).optional(),
avatarUrl: z.string().url().optional(),
});
export async function GET(req) {
const session = await getSession(req);
const user = await db.user.findUnique({ where: { id: session.userId } });
return Response.json(PublicUser.parse(user));
}
export async function PATCH(req) {
const session = await getSession(req);
const data = UpdateUser.parse(await req.json());
const updated = await db.user.update({ where: { id: session.userId }, data });
return Response.json(PublicUser.parse(updated));
}Two schemas: one for 'what the public sees' and one for 'what the owner can change.' Everything that is not in the allow list is dropped. Validation is not for formatting — it is the authorization boundary between the database and the API.
A real case
Parler's public API returned deleted posts, private messages, and GPS coordinates
In 2021, researchers pulled 70TB of Parler data by hitting public API endpoints that returned every field on every post — including precise geolocation metadata and data the UI had marked 'deleted.'
Related reading
References
Catch the fields your API shouldn't be sending — or accepting.
Flowpatrol reads every response, replays every write, and flags the properties that cross the authorization line.
Try it free