DocsHub
Security

Schema Validation as a Security Layer

Learn why MongoDB schema validation is also a security feature — defending against malformed data, oversized payloads, and type confusion attacks.

Schema Validation as a Security Layer

In the Schema Design section, we covered $jsonSchema validation as a way to maintain data quality — required fields, correct types, value ranges. That same validation is also one of your most important security defenses.

Every piece of data that enters your database originally came from a user — a form submission, an API request, a file upload. If your validation is weak, an attacker can use that input to corrupt your data, break your application logic, or in some cases gain unauthorized access.


Validation Is Your Last Line of Defense

Think of your application in layers:

User Input → Frontend Validation → API Validation → Database Validation → MongoDB

Frontend validation is easy to bypass — an attacker does not need to use your website at all. They can send requests directly to your API using tools like curl or Postman, completely skipping your frontend.

API-level validation (Mongoose schema validation) is your real defense. Database-level $jsonSchema validation is the final backstop — even if a bug in your API code skips Mongoose validation, MongoDB itself rejects bad documents.

Never trust frontend validation alone. A required field, a dropdown with fixed options, a max length on an input box — these are all things an attacker can simply ignore by sending requests directly to your API. Always validate again on the server.


Type Confusion Attacks

A type confusion attack happens when an attacker sends a value of an unexpected type, hoping your application code does not handle it correctly.

Example — The Login Query Problem

Consider this login code:

// VULNERABLE
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await User.findOne({ email: email, password: password });

  if (user) {
    return res.json({ message: 'Logged in' });
  }
  res.status(401).json({ message: 'Invalid credentials' });
});

If an attacker sends this request body:

{
  "email": "admin@school.com",
  "password": { "$gt": "" }
}

The query becomes:

db.users.findOne({ email: "admin@school.com", password: { $gt: "" } })

{ $gt: "" } means "password field is greater than an empty string" — which is true for any non-empty password hash. This query matches the admin user regardless of the actual password — the attacker is logged in without knowing the password at all.

The Fix — Strict Type Checking

With Mongoose schema validation, the password field is defined as String:

password: {
  type: String,
  required: true
}

But this alone does not stop the attack above — User.findOne() is a raw query, and Mongoose's findOne() filter is not validated against the schema by default.

The real fix is to validate the shape of the input itself before it reaches any database query:

// SAFE
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  // Explicitly check types BEFORE using them in a query
  if (typeof email !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ message: 'Invalid input' });
  }

  const user = await User.findOne({ email: email.toLowerCase() });

  if (!user || !(await user.comparePassword(password))) {
    return res.status(401).json({ message: 'Invalid email or password' });
  }

  res.json({ message: 'Logged in' });
});

Notice two things changed:

  1. We explicitly check typeof email !== 'string' and typeof password !== 'string' — rejecting any object, array, or other type immediately
  2. We never put password directly into a MongoDB query filter — we fetch the user by email only, then compare the password using bcrypt.compare() in application code

We cover this specific attack — NoSQL injection — in full detail in the next file.


Validating Input Shape with a Library

For larger applications, manually checking typeof on every field becomes repetitive and error-prone. Use a validation library like Joi or express-validator to define and enforce input shapes.

Example with Joi

npm install joi
const Joi = require('joi');

const loginSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required()
});

app.post('/api/login', async (req, res) => {
  const { error, value } = loginSchema.validate(req.body);

  if (error) {
    return res.status(400).json({ message: error.details[0].message });
  }

  const { email, password } = value;

  const user = await User.findOne({ email: email.toLowerCase() });

  if (!user || !(await user.comparePassword(password))) {
    return res.status(401).json({ message: 'Invalid email or password' });
  }

  res.json({ message: 'Logged in' });
});

Joi.string().email().required() — if email is anything other than a valid email string (including an object like { "$gt": "" }), validation fails immediately with a 400 Bad Request before any database query runs.


Limiting Payload Size

A malicious — or simply buggy — client could send an enormous request body, trying to overwhelm your server or exploit memory issues. Express has a built-in option to limit this:

const express = require('express');
const app = express();

// Limit JSON body size to 100kb
app.use(express.json({ limit: '100kb' }));

For our school system, a student record with embedded grades, address, and guardian info is at most a few KB. A request body over 100kb is almost certainly not legitimate.

app.post('/api/students', async (req, res) => {
  // If req.body is over 100kb, Express rejects it
  // before this handler even runs, with a 413 Payload Too Large error

  const student = await Student.create(req.body);
  res.status(201).json(student);
});

Defensive Array and Object Limits

We covered minItems and maxItems for arrays in the Schema Design section — these are also security limits:

subjects: {
  bsonType: "array",
  minItems: 1,
  maxItems: 10,
  items: { bsonType: "string" }
}

Without maxItems, an attacker could send a request with a subjects array containing 100,000 entries — bloating your document size, slowing down every query that touches it, and potentially pushing the document toward MongoDB's 16MB limit.

// Without maxItems — this would be ACCEPTED without the limit
{
  name: "Ali Hassan",
  subjects: ["Math", "Math", "Math", ... /* 100,000 times */]
}

Whitelist Fields — Avoid Mass Assignment

A mass assignment vulnerability happens when you pass the entire request body directly to a database operation, allowing an attacker to set fields they should not be able to control.

Example — Vulnerable Registration

// VULNERABLE
app.post('/api/register', async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json(user);
});

If the schema has a role field:

role: {
  type: String,
  enum: ["admin", "teacher", "student"],
  default: "student"
}

An attacker can register themselves as an admin by sending:

{
  "name": "Attacker",
  "email": "attacker@school.com",
  "password": "password123",
  "role": "admin"
}

Nothing in the schema prevents this — role: "admin" is a perfectly valid value according to the enum. The schema validates the shape of the data, but not who is allowed to set which fields.

The Fix — Explicitly Whitelist Fields

// SAFE
app.post('/api/register', async (req, res) => {
  const { name, email, password } = req.body;

  // Only these three fields can ever be set during registration
  // role defaults to "student" — never taken from user input
  const user = await User.create({
    name,
    email,
    password
    // role is NOT included — uses schema default
  });

  res.status(201).json({
    id: user._id,
    name: user.name,
    email: user.email,
    role: user.role
  });
});

By explicitly listing which fields come from req.body, any extra fields the attacker includes — like role: "admin" — are silently ignored.

Never pass req.body directly to Model.create() or Model.updateOne() for any endpoint where users can submit data — registration forms, profile updates, anything. Always destructure and explicitly list the fields you expect. This single habit prevents an entire category of vulnerabilities.


Strict Mode in Mongoose

By default, Mongoose schemas operate in strict mode — fields not defined in the schema are silently dropped when saving. This is already a security feature, but it is worth understanding.

const studentSchema = new mongoose.Schema({
  name: String,
  age: Number,
  grade: String
});

const Student = mongoose.model('Student', studentSchema);

// Attacker tries to set an undefined field
const student = await Student.create({
  name: "Attacker",
  age: 16,
  grade: "10th",
  isAdmin: true  // not defined in schema
});

console.log(student.isAdmin); // undefined — silently dropped

isAdmin: true is simply ignored because it is not in the schema. This is Mongoose's default behavior (strict: true) and you should never turn it off.

// NEVER do this
const studentSchema = new mongoose.Schema({ ... }, { strict: false });

With strict: false, any field — including ones an attacker invents — gets saved to the database.


Our School System — Security-Hardened Validation

Bringing it all together for the student creation endpoint:

const express = require('express');
const Joi = require('joi');
const Student = require('./models/Student');

const app = express();
app.use(express.json({ limit: '100kb' })); // payload size limit

// Define expected input shape
const createStudentSchema = Joi.object({
  name: Joi.string().min(2).max(100).required(),
  age: Joi.number().integer().min(5).max(25).required(),
  grade: Joi.string().valid("9th", "10th", "11th", "12th").required(),
  email: Joi.string().email().required(),
  address: Joi.object({
    city: Joi.string().required(),
    country: Joi.string().required()
  }).required(),
  subjects: Joi.array().items(Joi.string()).min(1).max(10).required()
});

app.post('/api/students',
  requireAuth,
  requireRole('admin'),
  async (req, res) => {
    // Step 1 — validate input shape (rejects type confusion, extra fields)
    const { error, value } = createStudentSchema.validate(req.body, {
      stripUnknown: true // remove any fields not defined in the Joi schema
    });

    if (error) {
      return res.status(400).json({ message: error.details[0].message });
    }

    // Step 2 — Mongoose schema validation runs automatically on create()
    // (required, enum, min/max, etc.)
    try {
      const student = await Student.create(value);
      res.status(201).json(student);
    } catch (err) {
      if (err.name === 'ValidationError') {
        return res.status(400).json({ message: err.message });
      }
      res.status(500).json({ message: 'Server error' });
    }
  }
);

Three layers working together:

  1. Payload size limit — rejects oversized requests before parsing
  2. Joi validation — rejects wrong types, unexpected fields, out-of-range values before touching the database
  3. Mongoose schema validation — final check, enforced at the model level, also catches anything Joi might have missed

Quick Reference

DefenseWhat it protects against
Strict typing (typeof checks, Joi)Type confusion attacks, NoSQL injection
Payload size limitsResource exhaustion, oversized documents
maxItems / maxLengthUnbounded arrays and strings
Explicit field whitelistingMass assignment — setting fields like role
Mongoose strict mode (default)Saving unexpected/extra fields
$jsonSchema validationFinal database-level backstop

Validation rules you wrote for data quality reasons are often already protecting you from security issues — but only if you think about them that way. Whenever you define a schema field, ask not just "what does valid data look like?" but also "what is the worst thing someone could send here, and does my validation actually stop it?"

On this page