DEV Community

Anas Sheikh
Anas Sheikh

Posted on

That .lean() Query Optimization Might Be Leaking Password Hashes in Your API Response

I've recommended .lean() in earlier posts, it's genuinely the right call for read-heavy queries, faster, lighter, no Mongoose document overhead. There's a real gap it opens up that's worth calling out specifically, because it silently undoes a common, otherwise-good security pattern, and the failure is invisible unless you specifically go check the actual API response.

The Setup That Looks Like a Solid Security Pattern

// models/User.ts
const UserSchema = new Schema({
  name: String,
  email: String,
  password: { type: String, select: false }, // excluded from queries by default
}, {
  toJSON: {
    transform: (doc, ret) => {
      delete ret.password; // extra safety net, strips it even if somehow selected
      delete ret.__v;
      return ret;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

This is a reasonable, defense-in-depth setup, select: false keeps the password out of normal queries by default, and the toJSON transform is a second safety net stripping it from anything that does get serialized, in case it was ever explicitly selected somewhere. Two layers, looks solid.

Where This Quietly Breaks

// app/api/users/route.ts
export async function GET() {
  const users = await User.find().select('+password').lean();
  // some legitimate internal reason to select password here, comparing hashes, an admin tool, etc.
  return Response.json({ users });
}
Enter fullscreen mode Exit fullscreen mode

.lean() returns plain JavaScript objects, not real Mongoose documents. That toJSON transform you configured on the schema is a method that exists on Mongoose document instances specifically, it never runs on a plain object. Response.json({ users }) calls JSON.stringify under the hood, which on a lean plain object just serializes every property directly, no transform, no stripping, nothing. If password was selected anywhere in that query chain, it goes straight into the API response, completely bypassing the safety net you specifically built to prevent exactly that.

Why This Is Genuinely Easy to Introduce

The select('+password') and .lean() combination often gets added at different times, for different reasons, by different people, or by the same developer months apart with the original toJSON safety net long forgotten. Someone adds .lean() to a query for a legitimate performance reason, weeks or months after the select('+password') was added for some specific internal need, comparing a password during a migration script, an admin debugging tool, and neither change alone looks dangerous in isolation. The combination is what's dangerous, and nothing about reviewing either change independently reveals that.

The Actual Fix: Never Rely on toJSON as Your Only Safety Net

The real lesson here isn't "don't use .lean()", it's genuinely great for performance and you should keep using it. The lesson is that toJSON transforms are not a reliable safety net for anything you might combine with .lean(), so the actual stripping needs to happen explicitly, regardless of whether the query happens to be lean or not.

// lib/serializers/user.ts
export function toSafeUser(user: any) {
  const { password, __v, ...safe } = user;
  return safe;
}
Enter fullscreen mode Exit fullscreen mode
// app/api/users/route.ts
import { toSafeUser } from '@/lib/serializers/user';

export async function GET() {
  const users = await User.find().lean(); // no need to select password here at all
  return Response.json({ users: users.map(toSafeUser) });
}
Enter fullscreen mode Exit fullscreen mode

An explicit serializer function works identically whether the input came from a lean query or a real document, since it's just plain object destructuring, not relying on a Mongoose-specific lifecycle hook that only fires under certain conditions.

The Broader Rule This Points To

Never assume a security-relevant transform tied to a document lifecycle method still applies once you've introduced .lean(), .toObject(), or any pattern that returns a plain object instead of a real document instance. toJSON, toObject transforms, and virtuals are all Mongoose document features, tied specifically to the document class, not properties of the raw data itself. The moment you're working with a plain object, none of that schema-level behavior comes along automatically, and any security assumption riding on it needs to be re-verified explicitly, not assumed to still hold.

Check Your Own API Routes

grep -rn "lean()" --include="*.ts" app/api/
Enter fullscreen mode Exit fullscreen mode

For each match, check two things, does the underlying query ever select a genuinely sensitive field, and does the response actually get filtered explicitly, not just relying on a schema-level transform that a lean query never triggers. If a route selects a sensitive field for some legitimate internal reason and also calls .lean(), that combination is worth verifying directly against the actual JSON response, not just the code.


If you've got .lean() anywhere near a query that ever selects a genuinely sensitive field, go check the actual raw response, not just the code, this specific combination is exactly the kind of thing that looks completely fine on read-through and only reveals itself in the real serialized output. Drop what you find in the comments.

Get the templates: https://pixelanas.gumroad.com


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)