Protect Routes and Keep Users Apart: Auth Middleware and Ownership Checks

MERN Stack Full Course: MongoDB, Express, React and Node.js · Authentication and Security · 13 min read · Updated

Only my data

  1. A request to a protected route carries a token in the Authorization header.
  2. The protect middleware verifies the token and loads the user. No token or a bad one gives 401.
  3. It attaches the user to req.user and lets the request continue.
  4. Every database query includes the owner: find by id AND user. Never take the user id from the body.
  5. Another person's job gives 404, as if it did not exist. Nothing is leaked.

Tokens exist. Now every private route must require one, and each query must be limited to the person it belongs to. This lesson contains the most important rule of the whole project, stated in lesson 6: a person can only ever see and change their own jobs. Authentication alone does not give you that. Many real data leaks come from apps that check *that* you are signed in, but not *whose* data you are asking for.

The protect middleware

// Lesson 22: protect routes. Put this in front of any route that needs a signed-in person.
import User from '../models/User.js';
import { HttpError } from '../utils/HttpError.js';
import { verifyToken } from '../utils/token.js';

export const protect = async (req, res, next) => {
  // The client sends:  Authorization: Bearer <token>
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : '';
  if (!token) throw new HttpError(401, 'Please sign in');

  let payload;
  try {
    payload = verifyToken(token);
  } catch {
    throw new HttpError(401, 'Your session has expired. Please sign in again');
  }
  const user = await User.findById(payload.sub);
  if (!user) throw new HttpError(401, 'Please sign in'); // account was deleted after the token was issued
  req.user = user; // controllers read the signed-in person from here
  next();
};
  • It reads the Authorization header, expects the form Bearer <token>, and answers 401 if it is missing.
  • verifyToken throws for a forged or expired token; we turn that into a 401 with a helpful message.
  • User.findById(payload.sub) loads the person. If the account was deleted after the token was issued, we refuse, so tokens of deleted accounts are useless.
  • On success it puts the user on req.user and calls next(). Every later function can say req.user._id and know it came from a verified token, not from anything the client typed.

Notice that this async middleware throws HttpError and does not call next(err) itself. Express 5 forwards the rejection to the error handler for you.

Linking jobs to users

import mongoose from 'mongoose';

export const STATUSES = ['saved', 'applied', 'interview', 'offer', 'rejected'];

const jobSchema = new mongoose.Schema(
  {
    // A reference: this job belongs to one User. We store only that user's _id.
    user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
    company: { type: String, required: [true, 'company is required'], trim: true, maxlength: 120 },
    role: { type: String, required: [true, 'role is required'], trim: true, maxlength: 120 },
    location: { type: String, trim: true, maxlength: 120, default: '' },
    status: { type: String, enum: { values: STATUSES, message: 'status must be one of: ' + STATUSES.join(', ') }, default: 'saved' },
    salaryNote: { type: String, trim: true, maxlength: 120, default: '' },
    notes: { type: String, trim: true, maxlength: 2000, default: '' },
    appliedOn: { type: Date },
  },
  { timestamps: true },
);

// Every query now starts with "this user's jobs", so the indexes start with user too.
jobSchema.index({ user: 1, createdAt: -1 });
jobSchema.index({ user: 1, status: 1, createdAt: -1 });

jobSchema.set('toJSON', {
  virtuals: true,
  versionKey: false,
  transform: (doc, ret) => { delete ret._id; delete ret.user; return ret; },
});

export default mongoose.model('Job', jobSchema);

user: { type: ObjectId, ref: 'User', required: true, index: true } is a reference: it stores the owner's _id. The ref tells Mongoose which model it points to, so later you could use .populate('user') to fetch the owner's details. The toJSON transform now also deletes user, because the client does not need it. The indexes from lesson 18 now start with user, because every query now begins with “this person's jobs”.

Scoping every query

// Lesson 22: every query is limited to the signed-in person's own jobs.
import Job, { STATUSES } from '../models/Job.js';
import { HttpError } from '../utils/HttpError.js';
import { pickJobFields } from '../middleware/validate.js';

// Someone else's job answers 404, not 403, so we do not even confirm that the id exists.
const mustFind = (job) => {
  if (!job) throw new HttpError(404, 'Job not found');
  return job;
};
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const SORTS = { newest: { createdAt: -1 }, oldest: { createdAt: 1 }, company: { company: 1, createdAt: -1 } };

export const listJobs = async (req, res) => {
  const page = Math.max(1, parseInt(req.query.page, 10) || 1);
  const limit = Math.min(50, Math.max(1, parseInt(req.query.limit, 10) || 10));

  const filter = { user: req.user._id }; // <- the line that keeps people apart
  if (STATUSES.includes(req.query.status)) filter.status = req.query.status;
  const q = String(req.query.q || '').trim().slice(0, 80);
  if (q) {
    const re = new RegExp(escapeRegex(q), 'i');
    filter.$or = [{ company: re }, { role: re }, { location: re }];
  }

  const [items, total] = await Promise.all([
    Job.find(filter).sort(SORTS[req.query.sort] || SORTS.newest).skip((page - 1) * limit).limit(limit),
    Job.countDocuments(filter),
  ]);
  res.json({ items, page, limit, total, pages: Math.max(1, Math.ceil(total / limit)) });
};

export const getJob = async (req, res) => res.json(mustFind(await Job.findOne({ _id: req.params.id, user: req.user._id })));

export const createJob = async (req, res) => {
  // user comes from the token, never from the request body
  const job = await Job.create({ ...pickJobFields(req.body), user: req.user._id });
  res.status(201).json(job);
};

export const updateJob = async (req, res) => {
  const job = await Job.findOneAndUpdate({ _id: req.params.id, user: req.user._id }, pickJobFields(req.body), { new: true, runValidators: true });
  res.json(mustFind(job));
};

export const deleteJob = async (req, res) => {
  mustFind(await Job.findOneAndDelete({ _id: req.params.id, user: req.user._id }));
  res.status(204).end();
};

Read the changes against lesson 18. The list starts its filter with { user: req.user._id }. Reading, updating and deleting one job use findOne({ _id, user }), findOneAndUpdate({ _id, user }, ...) and findOneAndDelete({ _id, user }). If the id belongs to someone else, the result is null and the answer is 404, exactly as if the job did not exist. We use 404 rather than 403 on purpose: a 403 would confirm to a stranger that the id exists.

And in createJob, the owner is added by the server: { ...pickJobFields(req.body), user: req.user._id }. The owner is never read from the body. If it were, a client could create jobs in someone else's account by sending "user": "...". pickJobFields already drops that field, and putting user after the spread makes the rule impossible to break by accident.

import Job, { STATUSES } from '../models/Job.js';

export const jobStats = async (req, res) => {
  const rows = await Job.aggregate([
    { $match: { user: req.user._id } }, // aggregate does not cast ids for you, so we pass the ObjectId itself
    { $group: { _id: '$status', count: { $sum: 1 } } },
  ]);

  const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
  for (const r of rows) byStatus[r._id] = r.count;

  const total = Object.values(byStatus).reduce((a, b) => a + b, 0);
  const applied = total - byStatus.saved;
  const interviewRate = applied ? Math.round(((byStatus.interview + byStatus.offer) / applied) * 100) : null;

  res.json({ total, byStatus, interviewRate });
};

The statistics controller shows a subtle detail. In an aggregation, Mongoose does not convert an id string to an ObjectId for you, so $match needs the real ObjectId. req.user._id already is one, which is another reason to take the id from the loaded user and not from a string.

import { Router } from 'express';
import { listJobs, getJob, createJob, updateJob, deleteJob } from '../controllers/jobs.js';
import { jobStats } from '../controllers/stats.js';
import { checkId } from '../middleware/validate.js';
import { protect } from '../middleware/auth.js';

const router = Router();
router.use(protect); // everything below needs a signed-in person
router.param('id', checkId);

router.get('/', listJobs);
router.get('/stats', jobStats);
router.post('/', createJob);
router.get('/:id', getJob);
router.put('/:id', updateJob);
router.delete('/:id', deleteJob);

export default router;

router.use(protect) at the top applies the middleware to every route below it, so a new route added later is protected by default. Secure by default is safer than remembering to add a guard each time. The auth router uses protect only on GET /me, which returns the signed-in user and is what the React app calls to restore a session.

import { Router } from 'express';
import { register, login, me } from '../controllers/auth.js';
import { protect } from '../middleware/auth.js';

const router = Router();
router.post('/register', register);
router.post('/login', login);
router.get('/me', protect, me);
export default router;

Proving it works

The updated smoke script (and requests.http) now registers two people, Ana and Ben. Ana creates a job. Ben tries to read, change and delete it and must get 404 each time. This is the test that matters most in the whole project. Add something like it for every resource you ever build.

// Calls your running API and checks the answers. Start the server (npm run dev), then:  npm run smoke
const base = process.env.API_URL || 'http://localhost:5000/api';
let failed = 0;
const check = (name, ok, extra = '') => {
  if (!ok) failed++;
  console.log(`${ok ? 'PASS' : 'FAIL'}  ${name}${ok ? '' : '  ' + extra}`);
};
const call = async (method, path, body, token) => {
  const headers = {};
  if (body) headers['Content-Type'] = 'application/json';
  if (token) headers.Authorization = `Bearer ${token}`;
  const res = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
  const text = await res.text();
  return { status: res.status, data: text ? JSON.parse(text) : null };
};

const stamp = Date.now();
let r = await call('GET', '/health');
check('health answers 200', r.status === 200 && r.data.ok === true);

r = await call('GET', '/jobs');
check('jobs without a token answer 401', r.status === 401, JSON.stringify(r));

r = await call('POST', '/auth/register', { name: 'Ana', email: `ana-${stamp}@example.com`, password: 'short' });
check('short password answers 400', r.status === 400 && r.data.details?.password, JSON.stringify(r));

r = await call('POST', '/auth/register', { name: 'Ana', email: `ana-${stamp}@example.com`, password: 'Password123' });
check('register answers 201 with a token and no hash', r.status === 201 && r.data.token && !JSON.stringify(r.data).includes('passwordHash'), JSON.stringify(r));
const ana = r.data?.token;

r = await call('POST', '/auth/register', { name: 'Ana', email: `ana-${stamp}@example.com`, password: 'Password123' });
check('same email again answers 409', r.status === 409, JSON.stringify(r));

r = await call('POST', '/auth/login', { email: `ana-${stamp}@example.com`, password: 'wrong-password' });
check('wrong password answers 401', r.status === 401, JSON.stringify(r));

r = await call('POST', '/auth/login', { email: `ana-${stamp}@example.com`, password: 'Password123' });
check('login answers 200 with a token', r.status === 200 && r.data.token, JSON.stringify(r));

r = await call('GET', '/auth/me', null, ana);
check('me returns the signed-in user', r.status === 200 && r.data.user.name === 'Ana', JSON.stringify(r));

r = await call('POST', '/jobs', { company: 'Smoke Test Ltd', role: 'Tester' }, ana);
check('create answers 201', r.status === 201 && r.data.status === 'saved', JSON.stringify(r));
const id = r.data?.id;

r = await call('POST', '/jobs', { company: '', status: 'maybe' }, ana);
check('bad body answers 400 with details', r.status === 400 && r.data.details?.company && r.data.details?.status, JSON.stringify(r));

r = await call('GET', '/jobs?q=smoke', null, ana);
check('search finds it', r.status === 200 && r.data.total === 1 && r.data.items[0].id === id, JSON.stringify(r));

r = await call('PUT', `/jobs/${id}`, { status: 'interview' }, ana);
check('update changes the status', r.status === 200 && r.data.status === 'interview', JSON.stringify(r));

r = await call('GET', '/jobs/stats', null, ana);
check('stats count it', r.status === 200 && r.data.byStatus.interview === 1 && r.data.total === 1, JSON.stringify(r));

// a second person must not see Ana's job
r = await call('POST', '/auth/register', { name: 'Ben', email: `ben-${stamp}@example.com`, password: 'Password123' });
const ben = r.data?.token;
r = await call('GET', `/jobs/${id}`, null, ben);
check("another user cannot read Ana's job (404)", r.status === 404, JSON.stringify(r));
r = await call('DELETE', `/jobs/${id}`, null, ben);
check("another user cannot delete Ana's job (404)", r.status === 404, JSON.stringify(r));

r = await call('DELETE', `/jobs/${id}`, null, ana);
check('owner can delete (204)', r.status === 204, JSON.stringify(r));
r = await call('GET', `/jobs/${id}`, null, ana);
check('deleted job answers 404', r.status === 404, JSON.stringify(r));

console.log(failed ? `\n${failed} check(s) failed` : '\nAll checks passed');
process.exit(failed ? 1 : 0);

Your task

  1. Run npm run seed, then npm run smoke against your running server.
  2. Call GET /api/jobs with no token, with a made-up token, and with a real one. Compare the answers.
  3. Create a job as one user. Copy its id, and request it with another user's token. What status do you get?
  4. Try to create a job with "user": "<another user's id>" in the body. Check in the database which user owns it.

How to check it worked

  • The smoke script passes all of its checks, including “another user cannot read Ana's job (404)”.
  • A token for a deleted user is refused.

Summary

  • protect verifies the token and puts the user on req.user.
  • Every query includes the owner; the owner comes from the token, never the body.
  • Answer 404 for someone else's data, and apply protect with router.use so new routes are safe by default.

Download the solution (mern-lesson-22.zip)

Frequently asked questions

Why not return 403 for another user's job?

403 tells the caller the thing exists but is forbidden, which leaks information. Answering 404 treats it as if it does not exist for them. Use 403 when the resource is known to the user but an action is not allowed.

Is checking the token enough to protect data?

No. It proves who is asking. You must also make every query only touch data that belongs to that person (or that their role allows). Missing the second step is a very common cause of data leaks.

What does router.use(protect) do?

It adds protect as middleware for every route defined after it on that router, so all of them require a valid token. Routes added later are protected automatically.