Scientyfic World

How to Build a RESTful API With Node.js and Express 5 (Validation, Auth, Tests)

Building a REST API with Node.js and Express takes an afternoon; building one that validates input, returns consistent errors, authenticates users, paginates, and is tested takes a bit more care....

Share:

Get an AI summary of this article

banner image for building RESTful APIs with Node and Express

Building a REST API with Node.js and Express takes an afternoon; building one that validates input, returns consistent errors, authenticates users, paginates, and is tested takes a bit more care. This guide builds a small task API that does all of that, with Express 5 and a handful of well-chosen libraries, and explains the design decisions as it goes.

What I tested: the API runs on Node.js 23 with Express 5.3, zod 4.6, jose 6.2, helmet 8.3 and express-rate-limit 8.7, storing data with Node’s built-in SQLite module. Five test groups written with Node’s test runner and supertest pass, covering registration and login, validation errors, task CRUD with ownership checks, cursor pagination, security headers and rate limiting.

The 2024 version of this post used Express 4 patterns, deprecated MongoDB connection options, a hard-coded JWT key ('yourPrivateKey') and deployment steps for Heroku’s discontinued free tier. If you want MongoDB specifically, the MERN stack tutorial covers Express with Mongoose; this post focuses on API design.

REST Design Rules This API Follows

RuleIn this API
Resources are nouns, actions are HTTP methodsGET /tasks, POST /tasks, PATCH /tasks/:id, DELETE /tasks/:id
Status codes mean something201 + Location on create, 204 on delete, 401/404/409/422/429 for specific failures
One error format everywhereRFC 9457 problem details (application/problem+json)
Validate everything that comes inzod schemas for bodies and query strings
Lists are paginatedCursor pagination with limit and nextCursor
Users only see their own dataEvery query is scoped to the signed-in user

Two choices worth explaining. PATCH is used for partial updates because clients send only the fields they change; PUT would mean “replace the whole resource”. And other users’ tasks return 404 rather than 403, so the API does not reveal which IDs exist.

Setup

mkdir tasks-api && cd tasks-api
npm init -y
npm pkg set type=module
npm install express zod helmet express-rate-limit jose
npm install -D supertest
tasks-api/
  src/
    app.js          # builds the Express app (used by tests and the server)
    index.js        # reads config and starts listening
    db.js           # SQLite schema
    auth.js         # password hashing and JWTs
    errors.js       # problem-details errors
    validate.js     # zod validation middleware
    routes/auth.js
    routes/tasks.js
  test/api.test.js

The Database

To keep the example self-contained it uses node:sqlite, the SQLite module built into Node.js. It printed an “experimental feature” warning on Node 23, so for production use a mature driver (better-sqlite3, or PostgreSQL with pg); the API code would barely change.

import { DatabaseSync } from 'node:sqlite';

export function openDb(file = ':memory:') {
  const db = new DatabaseSync(file);
  db.exec(`
    PRAGMA foreign_keys = ON;
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY,
      email TEXT NOT NULL UNIQUE,
      password_hash TEXT NOT NULL
    );
    CREATE TABLE IF NOT EXISTS tasks (
      id INTEGER PRIMARY KEY,
      user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
      title TEXT NOT NULL,
      done INTEGER NOT NULL DEFAULT 0,
      created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
    );
    CREATE INDEX IF NOT EXISTS tasks_user_id ON tasks(user_id, id);
  `);
  return db;
}

Errors: One Shape for Every Failure

Clients should not have to guess what an error looks like. Every error, from validation to “not found” to a crash, uses the RFC 9457 problem details format. Server errors are logged in full but returned with a generic message, so stack traces and SQL never reach the client:

// Errors follow RFC 9457 "problem details": one consistent JSON shape for every failure.
export class HttpError extends Error {
  constructor(status, title, detail, extra = {}) {
    super(detail ?? title);
    Object.assign(this, { status, title, detail, extra });
  }
}

export function notFound(req, res) {
  res.status(404).type('application/problem+json').json({ title: 'Not Found', status: 404, detail: `No route for ${req.method} ${req.path}` });
}

export function errorHandler(err, req, res, next) {
  if (err.type === 'entity.parse.failed') err = new HttpError(400, 'Bad Request', 'Malformed JSON body');
  const status = err.status ?? 500;
  if (status >= 500) console.error(err);
  res.status(status).type('application/problem+json').json({
    title: err.title ?? 'Internal Server Error',
    status,
    detail: status >= 500 ? 'Something went wrong' : err.detail,
    ...err.extra,
  });
}

Express 5 forwards errors thrown in async route handlers to the error middleware automatically, so routes can simply throw.

Validation With zod

A small middleware validates the body or query string against a schema and stores the parsed, typed result. A 422 response lists every invalid field at once:

import { HttpError } from './errors.js';

// Validate req.body / req.query with a zod schema and replace it with the parsed value.
export const validate = (schema, where = 'body') => (req, res, next) => {
  const result = schema.safeParse(req[where]);
  if (!result.success) {
    throw new HttpError(422, 'Validation Failed', 'The request has invalid fields', {
      errors: result.error.issues.map(i => ({ field: i.path.join('.'), message: i.message })),
    });
  }
  req.valid = { ...req.valid, [where]: result.data };
  next();
};

Authentication: Hashed Passwords and Short-Lived JWTs

Passwords are hashed with scrypt from Node’s built-in crypto module, with a random salt and a constant-time comparison. Tokens are signed with jose, expire after an hour, and the secret comes from the environment, never from source code:

import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
import { SignJWT, jwtVerify } from 'jose';
import { HttpError } from './errors.js';

const scryptAsync = promisify(scrypt);

export async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scryptAsync(password, salt, 64);
  return `${salt.toString('hex')}:${key.toString('hex')}`;
}

export async function verifyPassword(password, stored) {
  const [saltHex, keyHex] = stored.split(':');
  const key = await scryptAsync(password, Buffer.from(saltHex, 'hex'), 64);
  return timingSafeEqual(key, Buffer.from(keyHex, 'hex'));
}

export function tokens(secret) {
  const key = new TextEncoder().encode(secret);
  return {
    sign: userId =>
      new SignJWT({}).setProtectedHeader({ alg: 'HS256' }).setSubject(String(userId))
        .setIssuedAt().setExpirationTime('1h').sign(key),
    // Middleware: requires "Authorization: Bearer <token>" and sets req.userId.
    require: async (req, res, next) => {
      const header = req.get('authorization') ?? '';
      if (!header.startsWith('Bearer ')) throw new HttpError(401, 'Unauthorized', 'Missing bearer token');
      try {
        const { payload } = await jwtVerify(header.slice(7), key, { algorithms: ['HS256'] });
        req.userId = Number(payload.sub);
      } catch {
        throw new HttpError(401, 'Unauthorized', 'Invalid or expired token');
      }
      next();
    },
  };
}
import { Router } from 'express';
import { z } from 'zod';
import { rateLimit } from 'express-rate-limit';
import { hashPassword, verifyPassword } from '../auth.js';
import { HttpError } from '../errors.js';
import { validate } from '../validate.js';

const credentials = z.object({
  email: z.email().max(254).transform(e => e.toLowerCase()),
  password: z.string().min(8).max(128),
});

export function authRoutes(db, jwt) {
  const router = Router();
  // Slow down password guessing: 10 attempts per 15 minutes per IP.
  router.use(rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: 'draft-8', legacyHeaders: false }));

  router.post('/register', validate(credentials), async (req, res) => {
    const { email, password } = req.valid.body;
    try {
      const { lastInsertRowid } = db.prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)')
        .run(email, await hashPassword(password));
      res.status(201).json({ token: await jwt.sign(lastInsertRowid) });
    } catch (err) {
      if (String(err.message).includes('UNIQUE')) throw new HttpError(409, 'Conflict', 'Email already registered');
      throw err;
    }
  });

  router.post('/login', validate(credentials), async (req, res) => {
    const { email, password } = req.valid.body;
    const user = db.prepare('SELECT id, password_hash FROM users WHERE email = ?').get(email);
    // Same response whether the email or the password is wrong.
    if (!user || !(await verifyPassword(password, user.password_hash))) {
      throw new HttpError(401, 'Unauthorized', 'Invalid email or password');
    }
    res.json({ token: await jwt.sign(user.id) });
  });

  return router;
}

Login returns the same message for an unknown email and a wrong password, and the auth routes are rate-limited to slow down password guessing. For browser apps, many teams prefer an HTTP-only cookie session over a bearer token stored in JavaScript; bearer tokens suit mobile apps and server-to-server calls.

The Tasks Resource

import { Router } from 'express';
import { z } from 'zod';
import { HttpError } from '../errors.js';
import { validate } from '../validate.js';

const createTask = z.object({ title: z.string().trim().min(1).max(200) });
const updateTask = z.object({ title: z.string().trim().min(1).max(200), done: z.boolean() }).partial()
  .refine(v => Object.keys(v).length > 0, 'Provide title or done');
const listQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.coerce.number().int().positive().optional(), // id of the last item from the previous page
  done: z.enum(['true', 'false']).optional(),
});

const toJson = t => ({ id: t.id, title: t.title, done: Boolean(t.done), createdAt: t.created_at });

export function taskRoutes(db) {
  const router = Router();

  // GET /tasks?limit=20&cursor=123&done=false  -> newest first, cursor-based pagination
  router.get('/', validate(listQuery, 'query'), (req, res) => {
    const { limit, cursor, done } = req.valid.query;
    const rows = db.prepare(`
      SELECT * FROM tasks
      WHERE user_id = ? AND (? IS NULL OR id < ?) AND (? IS NULL OR done = ?)
      ORDER BY id DESC LIMIT ?`
    ).all(req.userId, cursor ?? null, cursor ?? null, done ?? null, done === 'true' ? 1 : 0, limit + 1);
    const page = rows.slice(0, limit);
    res.json({ data: page.map(toJson), nextCursor: rows.length > limit ? page.at(-1).id : null });
  });

  router.post('/', validate(createTask), (req, res) => {
    const { lastInsertRowid } = db.prepare('INSERT INTO tasks (user_id, title) VALUES (?, ?)').run(req.userId, req.valid.body.title);
    const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(lastInsertRowid);
    res.status(201).location(`/tasks/${task.id}`).json(toJson(task));
  });

  // Every lookup is scoped to the signed-in user, so other users' tasks are simply "not found".
  const findOwn = (req) => {
    const task = db.prepare('SELECT * FROM tasks WHERE id = ? AND user_id = ?').get(Number(req.params.id), req.userId);
    if (!task) throw new HttpError(404, 'Not Found', 'Task not found');
    return task;
  };

  router.get('/:id', (req, res) => res.json(toJson(findOwn(req))));

  router.patch('/:id', validate(updateTask), (req, res) => {
    const task = findOwn(req);
    const { title = task.title, done = Boolean(task.done) } = req.valid.body;
    db.prepare('UPDATE tasks SET title = ?, done = ? WHERE id = ?').run(title, done ? 1 : 0, task.id);
    res.json(toJson({ ...task, title, done }));
  });

  router.delete('/:id', (req, res) => {
    db.prepare('DELETE FROM tasks WHERE id = ?').run(findOwn(req).id);
    res.status(204).end();
  });

  return router;
}

Cursor pagination returns items older than the last ID the client saw. Unlike ?page=3 offsets, it does not skip or repeat items when new tasks are added between requests, and it stays fast on large tables because it uses the index.

Wiring It Together

import express from 'express';
import helmet from 'helmet';
import { tokens } from './auth.js';
import { errorHandler, notFound } from './errors.js';
import { authRoutes } from './routes/auth.js';
import { taskRoutes } from './routes/tasks.js';

export function createApp({ db, jwtSecret }) {
  const app = express();
  const jwt = tokens(jwtSecret);

  app.disable('x-powered-by');
  app.use(helmet());
  app.use(express.json({ limit: '100kb' }));

  app.get('/health', (req, res) => res.json({ ok: true }));
  app.use('/auth', authRoutes(db, jwt));
  app.use('/tasks', jwt.require, taskRoutes(db));

  app.use(notFound);
  app.use(errorHandler);
  return app;
}
import { openDb } from './db.js';
import { createApp } from './app.js';

const { JWT_SECRET, DATABASE_FILE = 'data.db', PORT = 3001 } = process.env;
if (!JWT_SECRET || JWT_SECRET.length < 32) throw new Error('Set JWT_SECRET to at least 32 random characters');

createApp({ db: openDb(DATABASE_FILE), jwtSecret: JWT_SECRET })
  .listen(PORT, () => console.log(`API on http://localhost:${PORT}`));

helmet sets security headers such as Content-Security-Policy and removes the X-Powered-By header. Start the server with a long random secret in .env:

# .env
JWT_SECRET=paste-a-long-random-string-here-at-least-32-chars
PORT=3001

$ npm start
API on http://localhost:3001
curl -X POST localhost:3001/auth/register -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse battery"}'
# {"token":"eyJ..."}

curl -X POST localhost:3001/tasks -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"title":"Write the API post"}'

Testing

Because createApp takes its database and secret as arguments, tests can use an in-memory database and never open a port. supertest sends real HTTP requests to the app:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { openDb } from '../src/db.js';
import { createApp } from '../src/app.js';

const app = createApp({ db: openDb(), jwtSecret: 'test-secret-that-is-at-least-32-characters' });
const api = request(app);

async function register(email) {
  const res = await api.post('/auth/register').send({ email, password: 'correct horse battery' }).expect(201);
  return { Authorization: `Bearer ${res.body.token}` };
}

test('auth: register, login, duplicates and bad passwords', async () => {
  await register('[email protected]');
  await api.post('/auth/register').send({ email: '[email protected]', password: 'correct horse battery' }).expect(409);
  await api.post('/auth/login').send({ email: '[email protected]', password: 'correct horse battery' }).expect(200);
  const bad = await api.post('/auth/login').send({ email: '[email protected]', password: 'wrong password!' }).expect(401);
  assert.equal(bad.type, 'application/problem+json');
  assert.equal(bad.body.detail, 'Invalid email or password');
});

test('validation errors list every bad field', async () => {
  const res = await api.post('/auth/register').send({ email: 'not-an-email', password: 'short' }).expect(422);
  assert.deepEqual(res.body.errors.map(e => e.field).sort(), ['email', 'password']);
  await api.post('/auth/login').set('Content-Type', 'application/json').send('{bad json').expect(400);
});

test('tasks: CRUD, ownership and pagination', async () => {
  const alice = await register('[email protected]');
  const bob = await register('[email protected]');

  await api.get('/tasks').expect(401);
  await api.get('/tasks').set({ Authorization: 'Bearer nope' }).expect(401);

  const created = await api.post('/tasks').set(alice).send({ title: '  Write post  ' }).expect(201);
  assert.equal(created.body.title, 'Write post');
  assert.equal(created.headers.location, `/tasks/${created.body.id}`);

  await api.get(`/tasks/${created.body.id}`).set(bob).expect(404);         // not visible to others
  await api.patch(`/tasks/${created.body.id}`).set(bob).send({ done: true }).expect(404);
  const updated = await api.patch(`/tasks/${created.body.id}`).set(alice).send({ done: true }).expect(200);
  assert.equal(updated.body.done, true);
  await api.patch(`/tasks/${created.body.id}`).set(alice).send({}).expect(422);

  for (let i = 1; i <= 5; i++) await api.post('/tasks').set(alice).send({ title: `Task ${i}` });
  const page1 = await api.get('/tasks?limit=4').set(alice).expect(200);
  assert.equal(page1.body.data.length, 4);
  assert.equal(page1.body.data[0].title, 'Task 5');                        // newest first
  const page2 = await api.get(`/tasks?limit=4&cursor=${page1.body.nextCursor}`).set(alice).expect(200);
  assert.equal(page2.body.data.length, 2);
  assert.equal(page2.body.nextCursor, null);
  const open = await api.get('/tasks?done=false').set(alice).expect(200);
  assert.equal(open.body.data.length, 5);
  await api.get('/tasks?limit=1000').set(alice).expect(422);

  await api.delete(`/tasks/${created.body.id}`).set(alice).expect(204);
  await api.get(`/tasks/${created.body.id}`).set(alice).expect(404);
});

test('security headers and unknown routes', async () => {
  const res = await api.get('/health').expect(200);
  assert.equal(res.headers['x-powered-by'], undefined);
  assert.ok(res.headers['content-security-policy']);
  const nf = await api.get('/nope').expect(404);
  assert.equal(nf.type, 'application/problem+json');
});

test('rate limit on auth routes', async () => {
  let last;
  for (let i = 0; i < 12; i++) last = await api.post('/auth/login').send({ email: '[email protected]', password: 'whatever123' });
  assert.equal(last.status, 429);
  assert.ok(last.headers['ratelimit-policy'] || last.headers['ratelimit']);
});
$ npm test
✔ auth: register, login, duplicates and bad passwords
✔ validation errors list every bad field
✔ tasks: CRUD, ownership and pagination
✔ security headers and unknown routes
✔ rate limit on auth routes
ℹ pass 5

Before You Deploy

  • Serve over HTTPS only, typically behind a load balancer or platform that terminates TLS. If you run behind a proxy, set Express’s trust proxy so rate limiting sees real client IPs.
  • Add CORS only for the origins that need it, using the cors package with an explicit origin list.
  • Use a production database and run migrations as part of deployment.
  • Add structured logging and request IDs, and monitor error rates.
  • Document the API with an OpenAPI description so clients can generate types.
  • Containerize it if your platform expects images; see building a CI/CD pipeline with Docker.

Conclusion

A good Express API is mostly consistency: predictable URLs and methods, meaningful status codes, one error format, validation at the edge, authentication that never stores plain passwords or hard-coded secrets, pagination for every list, and tests that exercise real HTTP requests. Express 5 removes much of the old async boilerplate, so the code that remains is the code that matters. To call this API from a front end, see how to make an HTTP request.

FAQs

What changed in Express 5 for REST APIs?

The biggest practical change is that errors thrown or rejected in async route handlers are passed to the error-handling middleware automatically, so you no longer need try/catch wrappers in every route.

Which status code should I use for validation errors?

400 for requests that cannot be parsed, such as malformed JSON, and 422 for well-formed requests with invalid field values. Whichever you choose, be consistent and include the field-level details.

Should I use PUT or PATCH to update a resource?

Use PATCH for partial updates where the client sends only changed fields, and PUT when the client sends a full replacement of the resource.

How should I store passwords in a Node.js API?

Never store them in plain text or with a fast hash like SHA-256. Use a slow, salted password hash such as scrypt (built into Node), bcrypt or Argon2, and compare in constant time.

Offset or cursor pagination?

Offset pagination (page and limit) is simple but can skip or repeat items when data changes and slows down on large tables. Cursor pagination, based on the last item’s ID or timestamp, is stable and uses indexes efficiently.

How do I test an Express API?

Export a function that builds the app, then use supertest to send real HTTP requests to it in your test runner, with a test database such as in-memory SQLite.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts