Protecting routes
Guards, public/admin splits, server-owned fields and per-user data.
Add a guard#
The library builds routes; deciding who may call them is yours. Pass guards in middlewares and they run before every route on that router — all six.
createRouter({
model: Order,
middlewares: [auth.requireAuth, auth.requireRole('staff')],
allowedFields: ['status', 'note'],
});
auth.requireAuth and auth.requireRole come from createAuthRouter, but any Express middleware works — your own JWT check, express-rate-limit, a tenant resolver. After requireAuth, req.auth holds { userId, role, claims }.
Because middlewares applies to every route, different rules for reads and writes need two routers (below) or hand-wired routes.
Public reads, staff writes#
The most common shape. Mount two routers on one model, each with its own allowlists — share the read configuration, differ on who can write.
const catalogue = {
model: Product,
orderBy: '-createdAt',
query: ['category', 'isFeatured'],
search: ['name', 'description'],
filterableFields: ['price', 'category'],
sortableFields: ['price', 'name', 'createdAt'],
};
// Refuse every method that isn't a read.
const readOnly = (req, res, next) =>
['GET', 'HEAD', 'OPTIONS', 'QUERY'].includes(req.method)
? next()
: res.status(405).json({ success: false, error: 'Method not allowed.' });
// Anyone: read
app.use('/api/products', createRouter({ ...catalogue, middlewares: [readOnly], allowedFields: [] }));
// Staff: read and write
app.use('/api/admin/products', createRouter({
...catalogue,
middlewares: [auth.requireAuth, auth.requireRole('staff', 'admin')],
allowedFields: ['name', 'price', 'description', 'category', 'isFeatured'],
}));
allowedFields: [] does not make a router read-only. It stops POST and PATCH from writing any field, but DELETE /:id has no body to filter and still deletes. A public router needs the readOnly guard (or hand-wired GET routes only). Keep allowedFields: [] as well — it's a second lock if the guard is ever removed.
Server-owned fields#
Fields like author, ownerId or tenantId come from the signed-in user, never from the body. Keep them out of allowedFields — so anything a client sends for them is stripped — and set them in validate.create:
app.use('/api/posts', createRouter({
model: Post,
middlewares: [auth.requireAuth],
allowedFields: ['title', 'body', 'tags'], // no 'author'
validate: {
create: (payload, { req }) => ({ ...payload, author: req.auth.userId }),
},
}));
Users see only their own records#
The library has no concept of ownership, so per-user data takes three pieces. Skip any one and it leaks:
- Lists: force the owner filter onto every
GET /, and turn offQUERY /(its filter comes from the body, which your middleware doesn't touch). - Single records: check ownership on every
/:id— the list filter doesn't cover them. - Creates: stamp the owner from the token.
import express from 'express';
import mongoose from 'mongoose';
import { createRouter, HttpError } from 'express-controller-sets';
// 1. Lists: pin ownerId to the caller. Express 5 re-parses req.query on every
// read, so assigning to it does nothing — shadow it with a real property.
const onlyMine = (req, res, next) => {
Object.defineProperty(req, 'query', {
value: { ...req.query, ownerId: String(req.auth.userId) },
writable: true, configurable: true, enumerable: true,
});
next();
};
// 2. Single records: 404 unless the caller owns it. 404, not 403 — don't
// confirm that someone else's record exists.
const ownsNote = async (req, res, next) => {
if (!mongoose.isValidObjectId(req.params.id)) return next(); // the handler answers 400
const mine = await Note.exists({ _id: req.params.id, ownerId: req.auth.userId });
if (!mine) throw new HttpError(404, 'Entry not found.');
next();
};
const notes = express.Router();
notes.use(auth.requireAuth, onlyMine);
notes.use('/:id', ownsNote);
notes.use(createRouter({
model: Note,
orderBy: '-createdAt',
query: ['ownerId'], // the pinned filter must be in query
enableQuery: false, // 1b. QUERY would bypass onlyMine
allowedFields: ['title', 'body'], // no ownerId
validate: { create: (p, { req }) => ({ ...p, ownerId: req.auth.userId }) }, // 3. stamp the owner
}));
app.use('/api/notes', notes);
Tested end to end: a user cannot list, read, change or delete another user's notes, even by sending ?ownerId= themselves. With caching on, the pinned filter is part of the cache key, so users never share entries.
Rate limits#
Nothing is rate-limited by default. Add a limiter to middlewares, and a tighter one on anything that gets guessed at — sign-in, password reset:
import rateLimit from 'express-rate-limit';
const api = rateLimit({ windowMs: 60_000, limit: 120 });
createRouter({ model: Product, middlewares: [api], allowedFields: [] });
For the auth routes, see Authentication → Rate-limit sign-in.
Before you go to production#
Every router has
allowedFieldsOr
blockedFields. No startup warning about unprotected models. Server-owned fields (role,ownerId) are not in it.Every write is guarded
Routers that accept
POST,PATCHorDELETEhave an auth guard inmiddlewares. Public routers have areadOnlyguard.Per-user data checks
/:idOwner-scoped routers check ownership on single records and set
enableQuery: false.Middleware order is right
express.json()before the routers,errorHandlerafter them.Secrets come from the environment
token.secretis 32+ characters and not in the repository.legacyModeandallowRawRegexare offOn every public router.
Uploads are private unless meant to be public
upload.acl: 'public-read'only for content like product photos.