Building a Production REST API With Node.js and Express

Build a REST API in Node.js and Express the way production systems need: input validation, JWT auth, status codes, rate limiting, error handling.

Building a Production REST API With Node.js and Express
Photo by Kelly Sikkema / Unsplash
Last Updated August 4, 2026

This is a full rewrite of the original 2021 walkthrough, not a patch. It now targets Node.js 24, the current Active LTS release (Node 26 is the newer "Current" release but doesn't reach LTS status until October 2026, so it's not yet the safe default for production). The rewrite adds input validation, password hashing, JWT authentication, rate limiting, and centralized error handling — none of which existed in the original — and follows the site's current style guide. Treat the 2021 version as retired; nothing in it should be copied as-is.

The original version of this article got you to "Hello, World!" It created a user model with a plaintext password field, a route that echoed back whatever req.body contained, and called it done. That's not a REST API. That's a database with extra steps and a security incident waiting for a Tuesday.

This rewrite builds the same API — users, CRUD routes, MongoDB — but the way it actually needs to work before anyone should point real traffic at it. Every section below exists because the original skipped something that breaks in production specifically, not in theory.

What Does "RESTful" Actually Require?

REST is not "JSON over HTTP." It's a set of architectural constraints Roy Fielding defined in his 2000 dissertation, and three of them are the ones tutorials skip and APIs pay for later.

Statelessness. Every request must carry everything the server needs to process it — no server-side session state between requests. This is why token-based auth (JWT, covered below) fits REST naturally and cookie-based sessions require extra care to stay stateless (a signed, self-contained cookie is fine; a server-side session store the client depends on is not).

Uniform interface, correctly used. GET doesn't mutate anything. POST creates. PUT replaces a whole resource. PATCH partially updates one. DELETE removes it. The original article's router.post('/users', ...) is fine, but there's no PUT, PATCH, or DELETE anywhere in it — half the interface is missing, which means "RESTful" was aspirational, not actual.

Resource-oriented URLs. /users/:id names a resource. /getUser?id=5 names a remote procedure call wearing a REST costume. The distinction matters because REST's cacheability and uniform-interface guarantees depend on URLs identifying resources, not actions.

Setting Up the Project With the Pieces That Were Missing

Run this against Node.js 24 (Active LTS as of this update — check node --version and upgrade if you're on 18 or 20, both of which have reached end-of-life). Same starting point as the original, with the dependencies this API actually needs:

mkdir my-api && cd my-api
npm init -y
npm install express mongoose bcrypt jsonwebtoken express-validator helmet cors express-rate-limit dotenv
npm install --save-dev nodemon supertest jest

bcrypt hashes passwords. jsonwebtoken issues and verifies auth tokens. express-validator validates and sanitizes input. helmet sets security-related HTTP headers. cors controls cross-origin access explicitly instead of leaving it wide open by omission. express-rate-limit throttles abusive clients. dotenv keeps secrets out of source control.

my-api/
├── src/
│   ├── models/
│   │   └── user.js
│   ├── routes/
│   │   └── user.js
│   ├── middleware/
│   │   ├── auth.js
│   │   ├── validate.js
│   │   └── errorHandler.js
│   ├── controllers/
│   │   └── userController.js
│   └── app.js
├── .env
├── .env.example
├── .gitignore
└── package.json

The controllers/ and middleware/ directories didn't exist in the original structure. That's not incidental — a route file that both validates input and touches the database directly is a route file you can't unit-test without spinning up MongoDB, and it's how the original ended up with error handling that just forwarded whatever Mongoose threw straight to the client.


We recommend reading CVE-2026-31431 "Copy Fail": How a 9-Year-Old Linux Kernel Bug Gives Any Local User Root in 732 Bytes to continue reading our selection of content. Different layer of the stack, same lesson: code that looks correct and passes every happy-path test can still hand an attacker exactly what the 403 check above exists to prevent.
CVE-2026-31431 “Copy Fail”: How a 9-Year-Old Linux Kernel Bug Gives Any Local User Root in 732 Bytes
CVE-2026-31431 chains AF_ALG, splice(), and authencesn’s ESN scratch write into a deterministic 4-byte page cache write that gives an unprivileged local user root. Full technical breakdown, exploit mechanics, detection, mitigation, and patch status per distro.

Why Does Password Storage Need bcrypt, Specifically?

The original userSchema stored password: { type: String, required: true } with no hashing anywhere in the file. That means the database holds plaintext passwords. Anyone with read access to the database — an attacker, a careless backup, a misconfigured admin panel — has every user's actual password.

// src/models/user.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
 
const userSchema = new mongoose.Schema({
    name: { type: String, required: true, trim: true },
    email: {
        type: String,
        required: true,
        unique: true,
        lowercase: true,
        trim: true,
    },
    password: { type: String, required: true, minlength: 8 },
}, { timestamps: true });
 
// Hash the password before saving, but only if it changed.
// Without the isModified check, every save() call -- including
// updating a user's name -- would re-hash an already-hashed password.
userSchema.pre('save', async function (next) {
    if (!this.isModified('password')) return next();
    const salt = await bcrypt.genSalt(12);
    this.password = await bcrypt.hash(this.password, salt);
    next();
});
 
userSchema.methods.comparePassword = function (candidate) {
    return bcrypt.compare(candidate, this.password);
};
 
// Never serialize the password hash into API responses.
userSchema.methods.toJSON = function () {
    const obj = this.toObject();
    delete obj.password;
    return obj;
};
 
module.exports = mongoose.model('User', userSchema);

bcrypt.genSalt(12) sets the cost factor — how many rounds of key stretching the hash goes through. Each increment doubles the computation cost. 12 rounds is a reasonable floor in 2026; it costs a legitimate login roughly 200-300ms of compute and costs an attacker running an offline brute-force attack against a stolen database dump the same multiplier per guess, at billions of guesses. That asymmetry is the entire point — bcrypt is deliberately slow so that stealing the hash doesn't mean immediately recovering the password.

The toJSON override matters independently of hashing. Even a bcrypt hash shouldn't round-trip to the client in an API response — it's an unnecessary exposure of internal state, and it's exactly the kind of thing that's easy to forget once and hard to notice you forgot.

How Do You Validate Input Before It Touches the Database?

The original router.post('/users', ...) passed req.body straight into new User(req.body). Mongoose's schema validation catches some malformed data, but it runs after the object is constructed, and it doesn't sanitize — it doesn't strip unexpected fields, doesn't normalize email casing consistently across all paths, and returns a raw Mongoose ValidationError object straight to the client, which leaks internal schema details (field names, validator types) that have no business in an API response.

// src/middleware/validate.js
const { body, validationResult } = require('express-validator');
 
const validateUserCreate = [
    body('name').trim().isLength({ min: 1, max: 100 }).escape(),
    body('email').isEmail().normalizeEmail(),
    body('password')
        .isLength({ min: 8 })
        .matches(/\d/).withMessage('Password must contain a number'),
    (req, res, next) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array() });
        }
        next();
    },
];
 
module.exports = { validateUserCreate };

This runs before the request reaches the controller. Bad input gets a 400 with a specific, structured error message — not a 500 with a stack trace, and not a record silently saved with a null field that breaks something three requests later.

What Do HTTP Status Codes Actually Signal?

The original API used exactly two status codes: 201 for created and generic error codes with no consistency. Status codes are part of the API's contract — clients (including the person debugging this API at 2 AM) rely on them to know what happened without parsing response bodies.

Code Meaning When to use it here
200 OK Success, has body Successful GET, PATCH
201 Created Resource created Successful POST
204 No Content Success, no body Successful DELETE
400 Bad Request Client sent malformed data Failed validation
401 Unauthorized No valid credentials Missing/invalid JWT
403 Forbidden Valid credentials, insufficient permission Authenticated user hitting another user's resource
404 Not Found Resource doesn't exist GET /users/:id for a nonexistent ID
409 Conflict Request conflicts with current state Duplicate email on registration
422 Unprocessable Entity Syntactically valid, semantically wrong Well-formed JSON with an invalid enum value
429 Too Many Requests Rate limit exceeded Covered below
500 Internal Server Error Unhandled server fault Bugs, not client mistakes

The distinction between 401 and 403 specifically gets conflated constantly. 401 means "I don't know who you are." 403 means "I know who you are, and you can't do that." Returning 401 for a permissions failure tells a legitimate, authenticated user their session died when it didn't — a debugging trap for exactly the kind of person building on top of your API.

How Does JWT Authentication Actually Work?

The original had no authentication whatsoever. GET /users/:id returned any user's full record to anyone who guessed an ID. Here's token-based auth that fits REST's statelessness constraint.

// src/controllers/authController.js
const jwt = require('jsonwebtoken');
const User = require('../models/user');
 
const TOKEN_EXPIRY = '1h';
 
async function login(req, res, next) {
    try {
        const { email, password } = req.body;
        const user = await User.findOne({ email });
 
        // Compare against a dummy hash even when no user is found.
        // Without this, a timing difference between "user not found"
        // (fast) and "user found, password wrong" (bcrypt.compare runs)
        // lets an attacker enumerate valid emails by response time.
        const validHash = user
            ? user.password
            : '$2b$12$invalidsaltinvalidsaltinvalidsaltinvalidsaltinvalidsal';
        const isMatch = await require('bcrypt').compare(password, validHash);
 
        if (!user || !isMatch) {
            return res.status(401).json({ error: 'Invalid credentials' });
        }
 
        const token = jwt.sign(
            { sub: user._id, email: user.email },
            process.env.JWT_SECRET,
            { expiresIn: TOKEN_EXPIRY }
        );
 
        res.json({ token, expiresIn: TOKEN_EXPIRY });
    } catch (err) {
        next(err);
    }
}
 
module.exports = { login };
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
 
function requireAuth(req, res, next) {
    const header = req.headers.authorization;
    if (!header || !header.startsWith('Bearer ')) {
        return res.status(401).json({ error: 'Missing or malformed token' });
    }
 
    const token = header.split(' ')[1];
 
    try {
        const payload = jwt.verify(token, process.env.JWT_SECRET);
        req.userId = payload.sub;
        next();
    } catch (err) {
        // Distinguish expired from invalid -- a client can react
        // differently to "refresh your token" versus "log in again."
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({ error: 'Token expired' });
        }
        return res.status(401).json({ error: 'Invalid token' });
    }
}
 
module.exports = { requireAuth };

A JWT is a signed, not encrypted, structure by default. Its payload is base64-encoded, not hidden — anyone can decode a JWT and read its claims without the secret. The signature only proves the token wasn't tampered with after issuance. Never put a password, an unhashed secret, or anything sensitive directly in a JWT payload; the "auth" it provides is authenticity, not confidentiality.

The dummy-hash comparison in login is worth sitting with. It's a two-line fix for a real, documented class of attack — user enumeration via response timing — and it's exactly the kind of defense that a "get it working" tutorial never includes because the API works without it. It just leaks who has an account.

Why Is a Single Long-Lived JWT the Wrong Tradeoff?

The login handler above issues one token, valid for an hour, and that's the entire session model. Push TOKEN_EXPIRY out to a week for user convenience and you've created a credential that, if stolen — through an XSS bug on the client, a leaked log line, a compromised dependency — stays valid for a week with no way to revoke it early, because a JWT's whole design point is that the server verifies it mathematically instead of checking a database. Keep it short for security and you're forcing users to log in every hour, which teams inevitably "fix" by loosening the expiry back out, undoing the original tradeoff.

The standard resolution is two tokens with different lifetimes and different jobs: a short-lived access token (the JWT from login, unchanged, still expiring in an hour) and a long-lived refresh token, stored server-side so it can actually be revoked, used only to mint new access tokens.

// src/models/refreshToken.js
const mongoose = require('mongoose');
const crypto = require('crypto');
 
const refreshTokenSchema = new mongoose.Schema({
    userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
    // Store a hash of the token, never the token itself -- this table
    // is the exact shape of a password table, and deserves the same
    // treatment: a database leak shouldn't hand out usable credentials.
    tokenHash: { type: String, required: true, unique: true },
    expiresAt: { type: Date, required: true },
    revokedAt: { type: Date, default: null },
}, { timestamps: true });
 
refreshTokenSchema.statics.hash = (token) =>
    crypto.createHash('sha256').update(token).digest('hex');
 
module.exports = mongoose.model('RefreshToken', refreshTokenSchema);
// src/controllers/authController.js (extended)
const crypto = require('crypto');
const RefreshToken = require('../models/refreshToken');
 
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;   // 30 days
 
async function issueRefreshToken(userId) {
    const rawToken = crypto.randomBytes(48).toString('hex');
    await RefreshToken.create({
        userId,
        tokenHash: RefreshToken.hash(rawToken),
        expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
    });
    return rawToken;   // Only the raw token ever leaves the server, once
}
 
async function refresh(req, res, next) {
    try {
        const { refreshToken } = req.body;
        if (!refreshToken) return res.status(401).json({ error: 'Missing refresh token' });
 
        const tokenHash = RefreshToken.hash(refreshToken);
        const stored = await RefreshToken.findOne({ tokenHash });
 
        if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
            return res.status(401).json({ error: 'Invalid or expired refresh token' });
        }
 
        // Rotation: revoke the token just used and issue a brand new one.
        // This turns token theft from a silent, indefinite compromise into
        // a detectable one -- if a stolen token gets used after the
        // legitimate client already rotated it, the reused old token is
        // already revoked, and that reuse is itself the signal to
        // invalidate the whole token family and force a real re-login.
        stored.revokedAt = new Date();
        await stored.save();
 
        const newRefreshToken = await issueRefreshToken(stored.userId);
        const user = await User.findById(stored.userId);
        const accessToken = jwt.sign(
            { sub: user._id, email: user.email },
            process.env.JWT_SECRET,
            { expiresIn: TOKEN_EXPIRY }
        );
 
        res.json({ token: accessToken, refreshToken: newRefreshToken, expiresIn: TOKEN_EXPIRY });
    } catch (err) {
        next(err);
    }
}
 
module.exports = { login, refresh };

Rotation is the detail that separates this from just "a second, longer token." Without it, a stolen refresh token is silently usable by an attacker for the entire 30-day window with no signal to anyone. With it, the moment the legitimate client and the attacker both try to use the same refresh token, one of those attempts hits an already-revoked token — which is a concrete, loggable event you can act on (revoke every token tied to that user, force a password reset), not a theoretical improvement.

Why hash the stored token the same way passwords get hashed? Because RefreshToken documents are exactly as sensitive as the User.password field was before bcrypt — a database dump that includes raw, usable refresh tokens hands an attacker standing access to every session, indefinitely, the same failure mode the original article's plaintext passwords had.

How Do You Rate-Limit Without Breaking Legitimate Users?

An unauthenticated /login endpoint with no rate limiting is an open invitation to credential-stuffing. The fix costs four lines:

// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
 
const loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,   // 15 minutes
    max: 5,                      // 5 attempts per window per IP
    message: { error: 'Too many login attempts, try again later' },
    standardHeaders: true,       // Send RateLimit-* headers
    legacyHeaders: false,
});
 
module.exports = { loginLimiter };

Apply it narrowly — to /login and any other credential-checking endpoint — rather than globally. A global rate limit tight enough to stop brute-forcing will also throttle a legitimate user's normal browsing of GET /users. Different endpoints have different abuse profiles; the limiter should match the endpoint, not the whole API.

What Does Centralized Error Handling Actually Buy You?

The original wrapped every route body in its own try/catch with its own res.status(...).send(error) — meaning the exact shape of an error response depended on which route you hit, and raw Mongoose/MongoDB errors (including field names and validator internals) went straight to the client.

// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
    console.error(err.stack);   // Log the full trace server-side, always
 
    if (err.name === 'ValidationError') {
        return res.status(400).json({ error: 'Validation failed', details: err.errors });
    }
 
    if (err.code === 11000) {   // MongoDB duplicate key error
        const field = Object.keys(err.keyPattern)[0];
        return res.status(409).json({ error: `${field} already in use` });
    }
 
    if (err.name === 'CastError') {   // Malformed ObjectId
        return res.status(400).json({ error: 'Invalid ID format' });
    }
 
    // Unknown errors: return a generic message.
    // Never leak err.message or err.stack to the client in production --
    // it can expose file paths, query structure, or library versions.
    const status = err.statusCode || 500;
    res.status(status).json({
        error: status === 500 ? 'Internal server error' : err.message,
    });
}
 
module.exports = { errorHandler };

Registered once, last, in app.js, after every route. Every controller can now call next(err) and stop thinking about response formatting entirely — one place decides what the client sees, which means one place to audit for information leakage instead of auditing every route individually.

How Do You Trace a Single Request Across Logs and Services?

console.error(err.stack) inside the error handler is honest about what it is: a single line dumped to stdout, with no way to correlate it to the specific request that caused it once you have more than one request in flight, let alone once this API is one of several services a request passes through. A user reporting "it broke around 2pm" gives you a haystack, not a log line.

// src/middleware/requestLogger.js
const crypto = require('crypto');
 
function requestLogger(req, res, next) {
    // Reuse an inbound correlation ID if an upstream service already
    // set one (common behind a gateway or load balancer); otherwise
    // mint a new one. This is what lets one ID trace a request across
    // every service it touches, not just this one.
    req.correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
    res.setHeader('x-correlation-id', req.correlationId);
 
    const start = process.hrtime.bigint();
    res.on('finish', () => {
        const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
        console.log(JSON.stringify({
            correlationId: req.correlationId,
            method: req.method,
            path: req.path,
            status: res.statusCode,
            durationMs: durationMs.toFixed(1),
            userId: req.userId || null,
        }));
    });
 
    next();
}
 
module.exports = { requestLogger };

Two choices here are doing the real work. Structured JSON output, instead of a formatted string, means log aggregation tools (whatever ingests these logs downstream) can filter and query by field instead of grep-and-pray. And logging on res.on('finish', ...) rather than at the top of the request captures the actual status code and duration after the handler ran — a log line written at request start can only ever say what came in, not what happened.

Update the error handler to include the same correlationId, and every log line for a single request — the entry, any error, the exit — now shares one value you can search for across the entire log stream:

// src/middleware/errorHandler.js (updated first line)
function errorHandler(err, req, res, next) {
    console.error(JSON.stringify({ correlationId: req.correlationId, error: err.stack }));
    // ...rest unchanged

Assembling the Hardened app.js

// src/app.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const helmet = require('helmet');
const cors = require('cors');
 
const userRoutes = require('./routes/user');
const authRoutes = require('./routes/auth');
const { errorHandler } = require('./middleware/errorHandler');
const { requestLogger } = require('./middleware/requestLogger');
 
const app = express();
 
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGIN, credentials: true }));
app.use(express.json({ limit: '10kb' }));   // Cap body size against payload-flood abuse
app.use(requestLogger);
 
// Connection pool sizing matters once this handles real concurrent load.
// The Mongoose/MongoDB driver default (100) is a reasonable ceiling for
// a single instance, but minPoolSize=0 by default means every burst of
// traffic after an idle period pays connection-establishment latency
// on the first requests. Pinning a floor avoids that cold-start cost.
mongoose.connect(process.env.MONGO_URI, {
    maxPoolSize: 50,
    minPoolSize: 5,
    socketTimeoutMS: 45000,   // Kill hung queries rather than let them pile up
});
 
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
 
app.use((req, res) => res.status(404).json({ error: 'Route not found' }));
app.use(errorHandler);   // Must be registered last
 
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
 
// Graceful shutdown: when the process manager (Docker, Kubernetes, PM2)
// sends SIGTERM, stop accepting new connections, let in-flight requests
// finish, then close the database connection -- in that order. Without
// this, a deploy or restart kills requests mid-flight and can leave
// half-written operations behind, and Mongoose's connection can be torn
// down while a query is still using it.
function shutdown(signal) {
    console.log(`${signal} received, shutting down gracefully`);
    server.close(async () => {
        await mongoose.connection.close();
        console.log('Server and database connection closed');
        process.exit(0);
    });
    // Force-exit if graceful shutdown hangs longer than 10 seconds
    setTimeout(() => process.exit(1), 10000).unref();
}
 
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

express.json({ limit: '10kb' }) is a small line with a real purpose: without a body size cap, a client can send a multi-gigabyte JSON payload and consume server memory parsing it before any validation middleware even runs.

The shutdown handler matters more than its length suggests. Container orchestrators send SIGTERM before killing a process during a deploy or a scale-down event, and give it a grace period before following up with SIGKILL. An app that doesn't listen for SIGTERM gets killed mid-request on every single deploy — which is invisible in low-traffic testing and shows up as a steady trickle of dropped requests the moment this runs behind a real deployment pipeline with rolling updates.

What Would a Complete Route File Look Like With All of This Applied?

// src/routes/user.js
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../middleware/auth');
const { validateUserCreate } = require('../middleware/validate');
const userController = require('../controllers/userController');
 
router.post('/', validateUserCreate, userController.createUser);
router.get('/', requireAuth, userController.listUsers);
router.get('/:id', requireAuth, userController.getUser);
router.patch('/:id', requireAuth, userController.updateUser);
router.delete('/:id', requireAuth, userController.deleteUser);
 
module.exports = router;
// src/controllers/userController.js
const User = require('../models/user');
 
async function createUser(req, res, next) {
    try {
        const user = new User(req.body);
        await user.save();
        res.status(201).json(user);
    } catch (err) {
        next(err);   // Centralized handler catches duplicate emails, etc.
    }
}
 
async function getUser(req, res, next) {
    try {
        const user = await User.findById(req.params.id);
        if (!user) return res.status(404).json({ error: 'User not found' });
 
        // Authorization, not just authentication:
        // a valid token shouldn't let you read anyone's record.
        if (req.userId !== user._id.toString()) {
            return res.status(403).json({ error: 'Forbidden' });
        }
 
        res.json(user);
    } catch (err) {
        next(err);
    }
}
 
module.exports = { createUser, getUser, /* updateUser, deleteUser follow the same pattern as getUser */ };

The 403 check inside getUser is doing work the original article never attempted: distinguishing "you're logged in" from "you're allowed to see this specific record." A JWT proves identity. It says nothing about authorization on its own — that's a check every controller that touches per-user data has to make explicitly.

What Happens When a POST Request Gets Sent Twice?

Mobile clients on flaky connections retry requests that time out — but a timeout doesn't mean the request failed, it means the client stopped waiting for a response that might still arrive. createUser above has no protection against this: a client that retries a timed-out POST /api/users after the first attempt actually succeeded will hit the 409 duplicate-email conflict from the error handler, which is at least safe, but a POST that doesn't have a natural uniqueness constraint (creating an order, charging a card, sending a notification) will happily execute twice with no error at all.

Idempotency keys close this: the client generates a unique key per logical operation (not per HTTP attempt) and sends it in a header. The server remembers which keys it has already processed and returns the original result instead of repeating the operation.

// src/middleware/idempotency.js
const IdempotencyRecord = require('../models/idempotencyRecord');
 
async function idempotent(req, res, next) {
    const key = req.headers['idempotency-key'];
    if (!key) return next();   // Optional: callers that don't care can skip it
 
    const existing = await IdempotencyRecord.findOne({ key, userId: req.userId });
    if (existing) {
        // Same key seen before -- return the original response verbatim
        // instead of re-running the operation. The client can retry as
        // many times as its network requires; the server only acts once.
        return res.status(existing.statusCode).json(existing.responseBody);
    }
 
    // Capture the response so it can be replayed on a future retry
    // with this same key, then store it after the real handler runs.
    const originalJson = res.json.bind(res);
    res.json = async (body) => {
        await IdempotencyRecord.create({
            key, userId: req.userId, statusCode: res.statusCode, responseBody: body,
        });
        return originalJson(body);
    };
 
    next();
}
 
module.exports = { idempotent };

Applied to a route that creates something with real-world side effects: router.post('/orders', requireAuth, idempotent, orderController.createOrder). The client sets Idempotency-Key to something it generates once per logical attempt (a UUID it holds onto across retries) — not once per HTTP request, which would defeat the entire purpose. This is the same pattern Stripe's API popularized, and it exists specifically because "the request timed out" and "the request failed" are not the same fact, and a retry-safe API has to treat them differently.


We recommend reading PHP MVC From Scratch: Routing, DI, and PDO Done Right to continue reading our selection of content. Same "roll your own" instinct, different stack — worth seeing how the same hardening principles (validation, centralized error handling, never trusting client input) show up in a PHP MVC context.
PHP MVC From Scratch: Routing, DI, and PDO Done Right
Extend a bare PHP MVC skeleton into something real: dependency injection, PDO-backed models with prepared statements, route params, and view escaping.

How Do You Paginate the User List Without Breaking Under Concurrent Writes?

listUsers was left as a stub in the route file above; here's the part that's easy to get subtly wrong. The obvious implementation is offset-based:

// Naive offset pagination -- has a real bug under concurrent writes
async function listUsersNaive(req, res, next) {
    const page = parseInt(req.query.page) || 1;
    const limit = 20;
    const users = await User.find().skip((page - 1) * limit).limit(limit);
    res.json(users);
}

This breaks in a specific, reproducible way: if a new user is created between a client fetching page 1 and page 2, every subsequent page shifts by one position, and the client either sees one user twice or skips one entirely. skip() doesn't know or care that the underlying result set changed between calls — it's counting rows from the start of the query every single time, against a collection that isn't standing still.

Cursor-based pagination avoids this by paginating from a stable position in the data itself rather than a row count:

// src/controllers/userController.js (listUsers, cursor-based)
async function listUsers(req, res, next) {
    try {
        const limit = Math.min(parseInt(req.query.limit) || 20, 100);   // Cap client-requested size
        const cursor = req.query.cursor;   // Opaque, base64-encoded _id of the last item seen
 
        const query = cursor
            ? { _id: { $gt: Buffer.from(cursor, 'base64').toString('utf8') } }
            : {};
 
        // Fetch one extra record to know whether a next page exists
        // without a separate count() query.
        const users = await User.find(query).sort({ _id: 1 }).limit(limit + 1);
        const hasMore = users.length > limit;
        const page = hasMore ? users.slice(0, limit) : users;
 
        const nextCursor = hasMore
            ? Buffer.from(page[page.length - 1]._id.toString()).toString('base64')
            : null;
 
        res.json({ data: page, nextCursor, hasMore });
    } catch (err) {
        next(err);
    }
}

Because MongoDB's default _id is monotonically increasing (it embeds a timestamp), sorting and filtering on _id gives a stable ordering that a newly inserted document can't retroactively disturb — a new user created during pagination lands after the cursor position the client already has, not somewhere in the middle of pages already fetched. The tradeoff against offset pagination is real: cursor pagination can't jump directly to "page 47," only to "the next page after where I am," which is the right tradeoff for infinite-scroll-style consumption and the wrong one for a UI that needs numbered page links — know which one the client actually needs before picking.

How Do You Test an API Like This?

// tests/user.test.js
const request = require('supertest');
const app = require('../src/app');
 
describe('POST /api/users', () => {
    it('rejects a password without a number', async () => {
        const res = await request(app)
            .post('/api/users')
            .send({ name: 'Test', email: '[email protected]', password: 'nopenope' });
        expect(res.status).toBe(400);
    });
 
    it('never returns the password field', async () => {
        const res = await request(app)
            .post('/api/users')
            .send({ name: 'Test', email: '[email protected]', password: 'secure123' });
        expect(res.body.password).toBeUndefined();
    });
});

That second test exists specifically to catch a regression of the exact bug the original article shipped — a password field leaking into an API response. Writing the test as a permanent guardrail is cheaper than re-auditing every controller by hand every time someone touches the model.


Frequently Asked Questions

Is MongoDB vulnerable to injection attacks like SQL databases?

Yes, differently. NoSQL injection in Mongoose typically comes from passing unsanitized req.body or req.query directly into a query — User.find(req.query) lets an attacker send ?password[$ne]=null and exploit MongoDB's query operators. Always validate and whitelist expected fields (as express-validator does above) rather than passing raw request data into a query.

Why use JWT instead of server-side sessions?

JWTs keep the API stateless, which matches REST's constraints and simplifies horizontal scaling — no shared session store needed across server instances. The tradeoff: a JWT can't be revoked before it expires without extra infrastructure (a token blocklist), so keep expiry times short and pair with refresh tokens for longer-lived sessions.

What's the difference between authentication and authorization in this API?

requireAuth (the JWT middleware) proves who is making the request. The 403 check inside getUser decides what they're allowed to do. Conflating the two — treating "has a valid token" as "can access anything" — is one of the most common real-world API vulnerabilities, formally named Broken Object Level Authorization in the OWASP API Security Top 10.

Do I need idempotency keys on every POST endpoint?

No — only on endpoints where a client retry could cause a real side effect if the operation ran twice (creating an order, charging a payment, sending a one-time notification). createUser above is already safe without one, because the unique email index turns an accidental duplicate into a 409 rather than a silent double-create. Add idempotency keys where the operation has no natural uniqueness constraint to fall back on.