5 min read

ORMs and the Silent Drop: How Undefined WHERE Filters Bypass Authorization




Sometime back, during a security review of the JavaScript ecosystem, we noticed a quiet failure mode in several popular ORMs: undefined values in WHERE clauses are silently dropped instead of rejected. That behavior is rooted in how JavaScript treats undefined (and how that differs from null and other values). It can bypass authorization and can even result in successful ATO issues; even when you believe the caller is already constrained by a user id, token, or other filter.

This post covers what happens, why, and how to defend against it.

Why This Is a JavaScript Behavior

In JavaScript, undefined is a first-class value. You can pass it around and store it on objects. The language does not treat “key exists, value is undefined” as an error.

A common convention in JS libraries is: if a value is undefined, treat it as “not supplied” and omit that key. When an ORM builds a WHERE clause from an object, it typically skips any key whose value is undefined.

That convention is convenient for optional filters. It is also exactly how authorization gaps appear unless you have guardrails that account for it.

What Actually Happens

Let’s say you are using a JS ecosystem web application with a typical ORM (Sequelize, Prisma, TypeORM, etc.) to query your data. You have assumed that all your queries are proper, because you always use an authentication filter, or an unguessable UUID, token, etc., for unauthenticated APIs when fetching values from the database.

Let’s walk through some examples of the expected behavior:

Example 1: One-time Tokens, UUIDs and secrets: dropping the token filter is account takeover (CRITICAL)

Password resets, magic links, email verification, invites, one-time download links, and coupon redemptions share a pattern:

  1. Look up a row by a high-entropy UUID or token.
  2. Enforce one-time use (usedAt: null / redeemed: false).
  3. Often enforce expiry and purpose.

Developers assume the UUID is unguessable, so the endpoint is safe. That assumption collapses if the ORM drops the token predicate, or drops one-time / expiry predicates while a weaker filter remains.

Exploit A: Omit the UUID → findOne returns someone else’s live token

// Intended: redeem exactly this reset token, only if unused and unexpired
const row = await PasswordResetToken.findOne({
  where: {
    token: req.query.token, // one-time UUID from the email link
    usedAt: null,
    expiresAt: { [Op.gt]: new Date() }
  }
});
if (!row) return res.status(400).send('Invalid or expired');
// ... set password for row.userId, then mark used

If req.query.token is missing (or a GraphQL argument is omitted), token is undefined and the ORM strips it. The query becomes roughly:

SELECT * FROM password_reset_tokens
WHERE used_at IS NULL AND expires_at > now()
LIMIT 1;

The attacker does not need to guess a UUID. They hit the redeem endpoint with no token (or an omitted GraphQL variable) and receive or consume the first unused, unexpired reset row in the table, typically belonging to another user. That is account takeover of whichever account still has a live token. The “unguessable secret” never participated in the query.

Exploit B: UUID present, but one-time or expiry filter dropped → replay

const usedAtFilter = onlyUnused ? null : undefined; // buggy flag
const row = await Token.findOne({
  where: {
    token: req.params.uuid, // attacker has a captured UUID
    usedAt: usedAtFilter,   // undefined → dropped
    expiresAt: { [Op.gt]: now }
  }
});

If usedAt is dropped, already-redeemed tokens match again. One-time becomes many-time: replay a used magic link, re-accept an invite, or re-download a “single use” file. If the expiry predicate is built from a variable that can be undefined and is omitted, expired UUIDs keep working.

Example 2: Session-scoped project lookup

The app loads private project details for a user, using projectId plus the authenticated user’s id:

const projectDetails = await Project.findOne({
  where: {
    id: projectId,
    userId: currentUser.id // assumed from logged-in session context
  }
});

Assumptions developers usually make:

  • The user is logged in, so userId is always available.
  • projectId comes from user input.
  • If either value is missing, nothing is returned.

What goes wrong

User session doesn’t exist: It is assumed that the middleware will always supply a user context. However, the middleware never considered cases related to an undefined session (could be a knowledge gap), or it allowed the application to pass through because some API endpoints were never meant to be authenticated. Hence, the middleware needs to allow requests without a session so that the application can function as a whole. So the assumption here was that middleware would pass the request through, but the respective queries would always filter using the user.id value (deemed to be secure, and always available). No one paid attention that an undefined user.id value can practically go through both the middleware check and the respective ORM query checks.

Attack path: An attacker never logs in. Their user context is never set. They call the endpoint with any projectId they can guess or enumerate. The userId filter is skipped; only projectId remains.

const projectDetails = await Project.findOne({
  where: {
    id: projectId,
    userId: currentUser.id // undefined → dropped; query filters by projectId only
  }
});

Will this attack work for authenticated users

This attack will fail: If the user were logged in, userId would be present and the filter will never be skipped. So in this shape the bug often presents as an authentication gap (unauthenticated access to tenant-scoped data) rather than classic cross-user IDOR with a valid session, though the root cause is the same silent drop.

How undefined Differs From null and Other Values

Only undefined is omitted. Roughly:

Value

Typical ORM behavior

undefined

Key skipped (no predicate)

null

Kept → IS NULL

0, '', false

Kept → equality match

Internally, ORMs often do something like if (value !== undefined) { addCondition(...) }. That is why the pitfall is JavaScript-specific: the language has a value that means “absent,” and the ORM uses it to drop the key. No error, just a shorter WHERE clause.

Mitigations

  1. Fail closed on required filters. Before querying, assert that authorization-critical values (userId, tokens, tenant ids) are defined. Reject the request if they are missing. Do not let them reach the ORM as undefined.
  2. Treat optional GraphQL / API args carefully. Mark schema fields as required when the filter must always apply. Use optional only when you can afford to skip that predicate.
  3. Prefer ORM strict modes where available. Some tools support stricter handling of undefined (for example, Prisma’s strict undefined checks). Enable them when you can.
  4. Defense in depth. Keep auth middleware that refuses unauthenticated access on protected routes, and keep query-level ownership / token filters, but never rely on either alone if the other can silently disappear.
  5. Code review and tests. Add tests that call sensitive endpoints with omitted params / missing session and assert 401/400, not “first matching row.”

The silent drop is easy to miss because the code looks correct and the ORM does not complain. Anywhere you put a security-critical value into a WHERE object, ask: what happens if this is undefined? If the answer is “the filter vanishes,” you have an authorization bug waiting to happen.

References:

Null and undefined in Prisma Client (Reference)
How Prisma Client handles null and undefined