DevLift
Back to Blog

Returning Sensitive Data in API Responses: Following One Column Out of the Database

A sensitive column passes through five hops on its way from Postgres to a browser, and a Prisma select only closes the first one — this traces all five with measured runs, including the RSC payload that a TypeScript props interface does not narrow.

Admin
September 14, 20266 min read0 views

Returning Sensitive Data in API Responses: Following One Column Out of the Database

Pick one column in your database that must never reach a browser. Not a category of data — one column. I am going to use User.resetToken, the single-use string your password-reset mail embeds. It is not hashed. Anyone holding it can take the account.

Then answer a narrow question: between that column and the HTML your visitor gets, how many places does it pass through, and which of them would have stopped it?

Five hops, in this order: the SQL query, the JavaScript object your handler builds, the props you hand a client component, the RSC payload React streams, and the response body itself. Everything below is a run against a throwaway SQLite database with Prisma 6.19.2 and React 19.2.3, with invented values — the "hash" and the token are made-up strings, not anyone's data. The schema:

model User {
  id               String  @id @default(cuid())
  email            String  @unique
  name             String
  avatarUrl        String?
  password         String
  resetToken       String?
  stripeCustomerId String?
  isAdmin          Boolean @default(false)
}

Hop 1: the query

const rawUser = await prisma.user.findUnique({ where: { id: 'u1' } });
console.log(Object.keys(rawUser));
[ 'id', 'email', 'name', 'avatarUrl', 'password',
  'resetToken', 'stripeCustomerId', 'isAdmin' ]

No select, so every scalar column came back, resetToken among them. The relation form does the same thing one level down:

const order = await prisma.order.findUnique({
  where: { id: 'o1' },
  include: { user: true },
});
console.log(Object.keys(order.user));
// [ 'id','email','name','avatarUrl','password','resetToken','stripeCustomerId','isAdmin' ]

Two things stop the column here, and they fail in opposite directions.

select is an allowlist. Name the four fields you want and a column added to the table next quarter is not one of them:

const picked = await prisma.user.findUnique({
  where: { id: 'u1' },
  select: { id: true, name: true, email: true, avatarUrl: true },
});
// keys: [ 'id','name','email','avatarUrl' ]

omit is a denylist, and it has one property select does not: you can set it once on the client and it holds for queries you never wrote.

const prismaGlobal = new PrismaClient({
  omit: { user: { password: true, resetToken: true } },
});
 
const viaInclude = await prismaGlobal.order.findUnique({
  where: { id: 'o1' },
  include: { user: true },   // the lazy form, still safe
});
console.log(Object.keys(viaInclude.user));
// [ 'id','email','name','avatarUrl','stripeCustomerId','isAdmin' ]

That is the only defence on this list that covers a handler someone adds in six months without reading this page. It is also the only one that fails open when the schema grows: add fraudScore and it is not in the omit list, so it ships. The two belong together — global omit as the floor under every query, select on the routes you can see.

Prisma's own guidance splits it the same way: select "when you want to return only a small subset of fields", omit "when the default result is mostly correct and you only want to remove a few sensitive or noisy fields" (Prisma docs, Excluding fields).

One thing that looks like a third option is not one. select cannot be used to subtract:

await prisma.user.findUnique({ where: { id: 'u1' }, select: { resetToken: false } });
// PrismaClientValidationError:
//   The `select` statement for type User needs at least one truthy value.

Loud, which is the good case. The quiet case is the mixed form, which does not error and does not exclude anything either — the false is simply ignored, and you get exactly the fields you marked true:

await prisma.user.findUnique({
  where: { id: 'u1' },
  select: { id: true, resetToken: false },
});
// { id: 'u1' }

So if you have ever written select: { password: false } expecting "everything except the password", you were either getting an exception or one field. Neither is what the code says.

Hop 2: the object your handler builds

Say hop 1 was not fixed. The row is in memory with resetToken on it, and the handler builds a response:

const spreadBody = { ...rawUser, token: 'invented-jwt' };
const allowlistBody = { id: rawUser.id, name: rawUser.name, email: rawUser.email, token: 'invented-jwt' };
 
console.log(Object.keys(spreadBody));
console.log(Object.keys(allowlistBody));
[ 'id','email','name','avatarUrl','password','resetToken','stripeCustomerId','isAdmin','token' ]
[ 'id','name','email','token' ]

The interesting part is not that run, it is the next one. I added a column to the schema — fraudScore Int @default(0) — pushed it, regenerated the client, and re-ran the identical handler code:

[ 'id','email','name','avatarUrl','password','resetToken','stripeCustomerId','isAdmin','fraudScore','token' ]
[ 'id','name','email','token' ]

The spread grew a field. Nobody touched the route. Nobody reviewed the route, because the diff was in schema.prisma.

This is also where the usual safety net gives a false pass. The standard advice is to assert on absence:

expect(body).not.toHaveProperty('password');
expect(body).not.toHaveProperty('resetToken');
expect(body).not.toHaveProperty('stripeCustomerId');

Those three assertions are green in both runs above, including the one that shipped fraudScore. A denylist test can only fail on the fields whose names you already typed. Against a spread it is a test that will be green on the day it matters. Assert the shape instead:

expect(Object.keys(body).sort()).toEqual(['email', 'id', 'name', 'token']);
⚠️
A mapping function that takes the whole row — toProfileResponse(user: User) — still lets the row into the same scope as your serializer. One stray reference re-expands it: { id: u.id, name: u.name, raw: u } serialises everything, and so does any nested ORM object you forgot to map, because JSON.stringify calls toJSON() on it and the default toJSON returns the whole document.

Measured, with a Mongoose-style document class:

const nested = { id: 'o1', total: 4200, user: userDoc };
JSON.stringify({ id: nested.id, total: nested.total, user: nested.user });
// {"id":"o1","total":4200,"user":{"id":"u1","name":"Sarah","password":"$2b$12$INVENTED","resetToken":"invented"}}

The top level is an allowlist. The leak is one level down.

The map so far

Rendering diagram...

Hops 1 and 2 are the whole of the usual advice. The next two are where a Next.js app leaks without ever calling NextResponse.json.

Hop 3: the props

A server component reads the row and hands it to a "use client" component. The client component's props declare two fields:

type Lesson = { id: string; title: string; isFree: boolean; content: string };
interface SidebarProps { lesson: { id: string; title: string } }
declare function Sidebar(p: SidebarProps): unknown;
declare const row: Lesson;
 
Sidebar({ lesson: row });
Sidebar({ lesson: { id: row.id, title: row.title, content: row.content } });

tsc --strict on that file:

ts1.ts(6,51): error TS2353: Object literal may only specify known properties,
  and 'content' does not exist in type '{ id: string; title: string; }'.

One error, on line 6. Line 5 — the one that hands over the whole row — is accepted. Excess-property checking fires on fresh object literals only; a variable is compared structurally, and a Lesson satisfies { id, title } by having those two fields. The type system is telling you the truth: the call is type-safe. It says nothing about what is in the object at runtime.

Hop 4: the RSC payload

So what actually crosses? I rendered both calls through react-server-dom-webpack/server.node at React 19.2.3 and printed the flight row for the client element:

A) whole row passed, props typed {id,title}:
  0:["$","$L3",null,{"lesson":{"id":"l_1","title":"Closures","isFree":false,
     "content":"# INVENTED PAID BODY\nBehind a paywall."}},"$1","$2",1]
  leaks body? true
 
B) allowlist built before the boundary:
  0:["$","$L3",null,{"lesson":{"id":"l_1","title":"Closures"}},"$1","$2",1]
  leaks body? false

React serialised the props object, all of it. The interface narrowed nothing, because the interface does not exist at runtime — it was erased before the bundle was built. That payload is inline in the page for an initial render, so the content sits in View Source of a page whose visible UI renders two fields.

This codebase has paid for that twice, and the fix is a comment in the data layer rather than a lint rule:

lessons: {
  orderBy: { sortOrder: "asc" },
  // `content` must NOT be selected here. This course tree feeds the
  // syllabus and the chapter sidebar, both `"use client"` components —
  // and React serialises the whole props object, not just the fields
  // the component's TypeScript interface declares.
  omit: { content: true },
},

Note which tool they reached for. The lesson rows need every other column, so subtracting one is the honest expression of the requirement; an allowlist there would be a fifteen-field list that someone has to extend every time the model grows. Allowlist by default, subtract when the default really is mostly right — and the reason it is safe to subtract here is that the same field is fetched deliberately, one row at a time, through a function that gates it.

Hop 5: the way out

Two more exits, both of which skip everything you did in hops 1 to 4.

A serializer bolted onto a send helper covers the routes that call the helper. I ran three handlers against the same over-fetched row: one through the helper, one added later that calls res.end(JSON.stringify(row)) directly, one that streams NDJSON.

/api/profile           leaks resetToken? false   {"id":"u1","name":"Sarah","email":"sarah@example.invalid"}
/api/profile/export    leaks resetToken? true    {"id":"u1","name":"Sarah","email":"sarah@example.invalid","password":"$2b$12$INVENTED","resetToken":"invented-reset-token"}
/api/profile/ndjson    leaks resetToken? true    {"id":"u1", ... same row, streamed}

An outbound serializer is a convention, and a convention is enforced by whoever remembers it. The query is not a convention.

Then error bodies. A caught database error is an object, and handlers echo it because it is convenient during development:

catch (e) { return { error: e.message, details: e.meta }; }
// {"error":"unique constraint",
//  "details":{"target":["email"],"record":{"id":"u1","password":"$2b$12$INVENTED"}}}

Whatever the driver attached to that error is now in a 500 body, and no select on the happy path touched it.

Two things that look like fixes

A role-shaped response is not an authorization check. This pattern shows up constantly:

const select = caller.isAdmin
  ? { id: true, name: true, email: true, isAdmin: true }
  : { id: true, name: true, avatarUrl: true };
const users = await prisma.user.findMany({ select });

Nothing in that handler rejects anyone. A non-admin still gets a row for every user in the table — an enumeration of your user base, from the endpoint you called the admin list. Decide access first and return 403; then shape the response.

Narrowing the relation while leaving the root wide. The nested select is the part people remember:

const trimmed = await prisma.order.findUnique({
  where: { id: 'o1' },
  include: { user: { select: { id: true, name: true } } },
});
console.log(Object.keys(trimmed), Object.keys(trimmed.user));
[ 'id', 'total', 'internalNote', 'userId', 'user' ] [ 'id', 'name' ]

internalNote is still there. include narrows the relation and leaves the root row untouched, so a query that reads as "fixed" is half-fixed. And where: { id: orderId } with no ownership predicate hands order o1 to anyone who can guess an id — which, now that the relation is selected down to { id, name }, means handing out a name that belongs to someone else.

The same trace on your own app

Four greps, in the order the hops run:

# hop 1 — queries with no allowlist
rg 'find(Unique|First|Many)\(\{[^}]*\}\)' --multiline -g '!*.test.*' | rg -v 'select|omit'
# hop 1 — the relation form
rg 'include:\s*\{[^}]*:\s*true' --multiline
# hop 2 and 3 — the row crossing a boundary whole
rg '\.\.\.(user|row|record|post|lesson)\b'
rg '<[A-Z]\w+\s+[a-z]\w*=\{(user|row|post|lesson)\}'

Then close hop 1 and watch hop 2 stop mattering. The same spread that shipped fraudScore a moment ago, over a row that was fetched with an allowlist:

const user = await prisma.user.findUnique({
  where: { id: 'u1' },
  select: { id: true, name: true, email: true, avatarUrl: true },
});
const body = { ...user };
console.log(Object.keys(body).sort());
console.log('resetToken present?', 'resetToken' in body);
[ 'avatarUrl', 'email', 'id', 'name' ]
resetToken present? false

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

The moment a credential reaches a remote, every copy of it is already out of your reach, which is why rotation is the fix and deleting the line is theatre.
AdminSeptember 1, 20269 min read
How OAuth 2.0 Works: One Flow, Attacked at Every Hop
Following a single authorization-code-plus-PKCE login request by request, showing at each hop what an attacker who owns that hop can do and which parameter takes the capability away.
AdminSeptember 10, 20269 min read
Two URLs, One Database: What a Pooler Actually Changes
I sent the same query down a transaction-mode pooler and a session-mode one into the same Postgres instance, and wrote down every place the answers diverged.
AdminSeptember 15, 20268 min read