Skip to main content

Command Palette

Search for a command to run...

Node.js and Express: API Security

Updated
6 min read
Node.js and Express: API Security
R

Somebody who likes to code

API security is a critical concern in modern application development. With the increasing number of data breaches and sophisticated attacks, implementing robust security measures for APIs is no longer optional. A single vulnerability can expose sensitive data, compromise user accounts, and damage our organization’s reputation.

This article is part of our comprehensive Node.js and Express series, focusing specifically on implementing essential API security practices. We will cover three fundamental security pillars every API should adopt: JWT verification, security headers, and CORS configuration.

By the end of this article, we will have practical, production-ready implementations of these security measures, along with a deep understanding of why each is crucial and how they work together to create a robust security posture.

Prerequisites

Before diving into implementation details, ensure we have the following in place:

JWT Verification

JSON Web Tokens (JWTs) have become the de facto standard for API authentication in modern applications. Their stateless nature — where all required information is contained within the token — makes APIs more scalable.

While the general recommendation is to use official libraries, the passport-azure-ad package (previously popular for Azure Entra ID integration) is now deprecated. Instead, we will use jsonwebtoken to verify and decode tokens, and jwks-rsa to obtain signing keys from the JWKS (JSON Web Key Set) endpoint.

npm i jwks-rsa jsonwebtoken

Add the following to the .env file:

TENANT_ID=<MY_API_APP_REGISTRATION_TENANT_ID>
CLIENT_ID=<MY_API_APP_REGISTRATION_CLIENT_ID>

In src/middlewares/errorHandler.js, add:

export class UnauthorizedError extends AppError {
  constructor(error) {
    super(error, 'unauthorized', 401, 'Unauthorized');
  }
}

Then create src/middlewares/auth.js with the following content:

import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { UnauthorizedError } from './errorHandler.js';

const client = jwksClient({
  jwksUri: `https://login.microsoftonline.com/${process.env.TENANT_ID}/discovery/v2.0/keys`,
  cache: true,
  cacheMaxAge: 600000,
});

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) {
      return callback(err);
    }
    const signingKey = key.getPublicKey();
    callback(null, signingKey);
  });
}

export function verifyJWT(options) {
  return (req, res, next) => {
    const authHeader = req.headers['authorization'];
    if (!authHeader) {
      return next(new UnauthorizedError('Missing authorization header'));
    }

    const parts = authHeader.split(' ');
    if (parts.length !== 2 || parts[0] !== 'Bearer' || !parts[1]) {
      return next(
        new UnauthorizedError(
          'Invalid authorization header format. Expected "Bearer <token>"'
        )
      );
    }
    const token = parts[1];
    jwt.verify(
      token,
      getKey,
      {
        algorithms: ['RS256'],
        audience: `api://${process.env.CLIENT_ID}`,
        issuer: `https://sts.windows.net/${process.env.TENANT_ID}/`,
      },
      (err, decoded) => {
        if (err) {
          return next(new UnauthorizedError(err.message));
        }
        if (options.scopes && options.scopes.length > 0) {
          const tokenScopes = decoded.scp ? decoded.scp.split(' ') : [];
          const hasScopes = options.scopes.every(scope =>
            tokenScopes.includes(scope)
          );

          if (!hasScopes) {
            return next(
              new UnauthorizedError(
                `Missing scopes: ${options.scopes.join(', ')}`
              )
            );
          }
        }
        req.user = decoded;
        next();
      }
    );
  };
}

When Azure Entra ID issues a JWT, it signs it with a private key. To verify the token, we need the corresponding public key, which we obtain from the JWKS endpoint. Each JWT contains a kid field in its header, which identifies the signing key. The getKey function handles extracting the kid and finding the matching public key using the jwksClient.

Once retrieved, jwt.verify uses the public key to validate the signature. We also check that the token was issued by our tenant (via the iss field), intended for our audience (via the aud claim), and signed with the expected algorithm. Optionally, the middleware verifies that required scopes are present. A successfully validated token is then attached to req.user for downstream use.

Update src/features/todos/routes.js with:

import express from 'express';
import { addTodo, addTodoSchema } from './addTodo.js';
import { findTodo, ensureTodoFound } from './findTodo.js';
import { checkTodo } from './checkTodo.js';
import { uncheckTodo } from './uncheckTodo.js';
import { listTodos, listTodosSchema } from './listTodos.js';
import { paginationParam } from '../../middlewares/paginationParam.js';
import { schemaValidator } from '../../middlewares/schemaValidator.js';
import { verifyJWT } from '../../middlewares/auth.js';

const router = express.Router();

router.param('todoId', ensureTodoFound);

router
  .post(
    '/',
    verifyJWT({ scopes: ['invoke'] }),
    schemaValidator({ body: addTodoSchema }),
    addTodo
  )
  .get('/:todoId', findTodo)
  .post('/:todoId/check', checkTodo)
  .post('/:todoId/uncheck', uncheckTodo)
  .get(
    '/',
    schemaValidator({ query: listTodosSchema }),
    paginationParam,
    listTodos
  );

export default router;

In the code above, we use our newly created middleware. To test the feature, we are using Postman to generate the token and include it in our request to the endpoint:

Securing Headers with Helmet

HTTP security headers are the first line of defense against many common web vulnerabilities. Helmet secures Express applications by setting various default HTTP headers.

  • Content-Security-Policy: Allow-list of permitted resources (scripts, styles, images, frames). Helps prevent XSS, data injection, and malicious resource loading.

  • Cross-Origin-Opener-Policy: Isolates browsing context from other origins. Helps enable cross-origin isolation and reduce side-channel attacks.

  • Cross-Origin-Resource-Policy: Controls whether other sites can load our resources (scripts, images). Prevents data leaks.

  • Origin-Agent-Cluster: Isolates JavaScript memory per origin, reducing cross-subdomain attacks.

  • Referrer-Policy: Controls what is sent in the Referer header. Prevents leaking sensitive query/path info.

  • Strict-Transport-Security: Forces browsers to use HTTPS, preventing downgrade attacks.

  • X-Content-Type-Options: Prevents MIME-sniffing. Mitigates injection attacks.

  • X-DNS-Prefetch-Control: Controls DNS prefetching (performance/privacy tuning).

  • X-Download-Options: Forces "Save As" on downloads (IE only).

  • X-Frame-Options: Prevents clickjacking by blocking <iframe> embedding.

  • X-Permitted-Cross-Domain-Policies: Restricts Adobe Flash/Acrobat cross-domain requests.

  • X-Powered-By: Removed by Helmet to avoid exposing server details.

  • X-XSS-Protection: Disabled by Helmet due to legacy browser issues.

Install Helmet:

npm i helmet

Enable in the src/server.js file:

import helmet from 'helmet';
const app = express();
app.use(helmet());

CORS Configuration

Cross-Origin Resource Sharing (CORS) is a browser security mechanism that restricts requests from different domains, protocols, or ports. Proper configuration is crucial:

  • Prevents unauthorized access: Stops malicious websites from making requests to our API on behalf of users.

  • Protects sensitive data: Prevents data from being shared with unauthorized origins.

  • Mitigates CSRF attacks: Adds an additional layer of defense.

Run the following command to install the CORS Express middleware:

npm install cors

Add to the .env file:

ALLOWED_ORIGIN=http://localhost:5000

The final src/server.js file will look like this:

import express from 'express';
import dotenv from 'dotenv';
import todosRoutes from './features/todos/routes.js';
import healthRoutes from './routes/health.js';
import { errorHandler, NotFoundError } from './middlewares/errorHandler.js';
import morgan from 'morgan';
import expressWinston from 'express-winston';
import logger from './config/logger.js';
import helmet from 'helmet';
import cors from 'cors';

process.on('uncaughtException', err => {
  console.error(err.name, err.message);
  process.exit(1);
});
dotenv.config();
const PORT = process.env.PORT || 3000;
const app = express();
app.use(helmet());
app.use(
  cors({
    origin: process.env.ALLOWED_ORIGIN,
  })
);
app.use(express.json());
app.use(morgan('dev'));
app.use(
  expressWinston.logger({
    winstonInstance: logger,
    msg: 'HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms',
  })
);
app.use('/health', healthRoutes);
app.use('/api/todos', todosRoutes);
app.all('/*splat', (req, res, next) => {
  const pathSegments = req.params.splat;
  const fullPath = pathSegments.join('/');
  next(new NotFoundError(`The requested URL /${fullPath} does not exist`));
});
app.use(
  expressWinston.errorLogger({
    winstonInstance: logger,
    msg: '{{err.message}} {{res.statusCode}} {{req.method}}',
  })
);
app.use(errorHandler);
const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

process.on('unhandledRejection', err => {
  console.error(err.name, err.message);
  server.close(() => {
    process.exit(1);
  });
});

You can find the code here. Thank you, and happy coding.

More from this blog

raulnq

171 posts

Somebody who likes to code