DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Your MongoDB Login Query Might Be Vulnerable to a One-Line Bypass

Most developers coming from a SQL background know to worry about SQL injection instinctively, string concatenation into a raw query feels obviously dangerous. MongoDB's query syntax being actual JavaScript objects creates a different, less intuitive version of the same underlying problem, and it's genuinely easy to introduce without it looking wrong at all.

The Vulnerable Version

// app/api/login/route.ts
export async function POST(request: Request) {
  const body = await request.json();

  const user = await User.findOne({
    email: body.email,
    password: body.password, // assume this is already hashed and compared correctly elsewhere
  });

  if (!user) {
    return new Response('Invalid credentials', { status: 401 });
  }

  // ... issue session
}
Enter fullscreen mode Exit fullscreen mode

This looks like completely standard login logic. The vulnerability isn't in the comparison itself, it's in what body.email and body.password are actually allowed to be.

Why This Is Exploitable

MongoDB query objects use operators like $ne (not equal), $gt (greater than), and $regex directly as object keys. If body.password is a plain string, findOne compares it normally, an exact match. But if an attacker sends JSON where password is itself an object, { "$ne": null }, that's not a plain value being compared anymore, it's a MongoDB query operator being injected directly into the query.

{
  "email": "admin@example.com",
  "password": { "$ne": null }
}
Enter fullscreen mode Exit fullscreen mode

{ password: { $ne: null } } translates to "find a user where password is not equal to null," which is true for essentially every real user account, since every account has some password value stored. If the attacker also knows or guesses a valid email, this query can return that user, fully bypassing the password check entirely, without ever knowing the actual password.

Why request.json() Makes This Worse Than You'd Expect

request.json() parses whatever JSON structure the client sends, faithfully, including nested objects. Nothing about calling .json() restricts the shape of what comes back to "an object with only string values." A client controls the entire shape of that body, and passing it straight into a Mongoose query without checking that shape first hands query-construction power directly to whoever's sending the request.

The Fix: Validate the Shape, Not Just the Presence

This is exactly why the Zod validation pattern from earlier matters here specifically, not just for user-friendly error messages, but as the actual thing preventing this class of vulnerability.

// app/api/login/route.ts
import { z } from 'zod';

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

export async function POST(request: Request) {
  const body = await request.json();
  const parsed = LoginSchema.safeParse(body);

  if (!parsed.success) {
    return new Response('Invalid credentials', { status: 401 });
  }

  const { email, password } = parsed.data; // guaranteed to be actual strings, never objects

  const user = await User.findOne({ email });
  if (!user || !(await bcrypt.compare(password, user.password))) {
    return new Response('Invalid credentials', { status: 401 });
  }

  // ... issue session
}
Enter fullscreen mode Exit fullscreen mode

z.string() rejects anything that isn't genuinely a string. { "$ne": null } fails this validation immediately, safeParse returns success: false, and the request never gets anywhere near the database query at all. This closes the vulnerability completely, not by trying to detect and block MongoDB operator syntax specifically, but by guaranteeing the value's actual type before it's ever used in a query.

This Isn't Limited to Login Forms

Any Mongoose query built from unvalidated user input carries the same risk, a search endpoint, a filter parameter, anything where a request body or query string value flows into a find, findOne, or updateOne call without first being confirmed as the expected primitive type.

// ❌ A search endpoint with the same underlying vulnerability
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const query = { name: searchParams.get('name') }; // usually a string, but not guaranteed by anything
  return Response.json(await User.find(query));
}
Enter fullscreen mode Exit fullscreen mode

URL search params are always strings by default, which makes this specific example safer than the JSON body case, but the same category of risk reappears the moment a request body, rather than a URL param, is the input source, and it's worth applying the same validate-the-shape discipline everywhere user input reaches a query, not just on the endpoints that feel obviously sensitive.

The Actual Rule

Never pass a parsed request body directly into a Mongoose query without validating that every field is the primitive type you expect, string, number, whatever the field is supposed to be. Zod, or any schema validation applied before the query runs, isn't just for clean error messages, it's the actual mechanism that prevents a client from injecting a MongoDB operator where a plain value belongs.


If you have any endpoint building a Mongoose query directly from request.json() without schema validation in front of it, that's worth checking today, not eventually, this is a genuinely exploitable, well-known vulnerability class, not a theoretical edge case. 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)