← Node & Express

REST APIs

What basic security measures would you apply in a REST API with Express?

Show answer — try answering out loud first

At a minimum: use helmet for secure HTTP headers, configure cors to control which origins can call the API, don't expose stack traces in production, apply rate limiting against abuse and brute force, prevent injection with parameterized queries and input validation, and don't hardcode secrets but read them from environment variables.

A public API receives requests from anyone, so you have to protect it in several layers:

  • helmet: secure headers. It's a middleware that adds HTTP headers that harden security (for example, they prevent the response from being interpreted as a different type or from being loaded in someone else's iframe). It's enabled with one line and already improves things quite a bit.
  • cors: origin control. CORS decides which domains in the browser can call your API. Without configuring it properly, any website could consume your API from the browser. It's restricted to the origins you trust.
  • Don't expose stack traces in production. If you return the full internal error to the client, you reveal file paths, versions and system details that help an attacker. In production you respond with a generic message and log the detail only in your logs.
  • Rate limiting. Limit how many requests an IP can make in a period. It protects against abuse and against brute force (for example, trying thousands of passwords at login). It's done with express-rate-limit.
  • Injection prevention. Never concatenate user input inside a query. Use parameterized queries (placeholders) and validate the input. This way the user's data is treated as data, not as code.
  • Don't hardcode secrets. API keys, database passwords and tokens do not go in the code. They go in environment variables (process.env), outside version control.
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');

const app = express();
app.use(express.json());

// 1) Secure HTTP headers (one line, big improvement)
app.use(helmet());

// 2) CORS: we only allow the origin of our frontend
app.use(
  cors({
    origin: 'https://mi-frontend.com', // don't use '*' in production with credentials
  })
);

// 3) Rate limiting: at most 100 requests per IP every 15 minutes
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // max requests per IP in that window
  message: { error: 'Demasiadas peticiones, inténtalo más tarde' },
});
app.use(limiter);

// 4) Secrets from environment variables, NEVER in the code
const apiKey = process.env.API_KEY; // defined outside the source code

app.get('/salud', (req, res) => {
  res.json({ estado: 'ok' });
});

// 5) Central error handler: do NOT expose stack traces in production
app.use((err, req, res, next) => {
  // The detail only goes to the server logs
  console.error(err);

  const enProduccion = process.env.NODE_ENV === 'production';
  res.status(500).json({
    error: 'Error interno del servidor',
    // We only show details outside production
    detalle: enProduccion ? undefined : err.message,
  });
});

app.listen(3000);

Example of a parameterized query to prevent SQL injection (using a PostgreSQL client like pg):

// BAD: concatenating user input allows SQL injection
// const query = `SELECT * FROM usuarios WHERE email = '${email}'`;

// GOOD: the email travels as a parameter ($1), never as part of the SQL text
const resultado = await db.query(
  'SELECT * FROM usuarios WHERE email = $1',
  [email]
);
# Secrets are passed through the environment, not written in the code
NODE_ENV=production API_KEY=mi-clave-secreta node app.js

Hardcoding secrets in the code (an API key or the database password written directly in a .js file) and pushing them to the repository. Once in the Git history, they stay exposed even if you delete them afterward. Secrets go in environment variables and the file that contains them (for example .env) is ignored in version control.

Another frequent mistake is returning the full stack trace to the client in production, revealing internal structure and versions that make an attack easier.

"I'd start with the basics that give a lot of value for little effort: helmet to add secure HTTP headers and cors properly configured to allow only the origins I trust, not an open asterisk. Then I make sure not to expose stack traces in production; I return a generic message to the client and keep the detail only in the logs. I add rate limiting with express-rate-limit to curb abuse and brute-force attacks, especially on endpoints like login. For injection, I always use parameterized queries and validate the input, so that the user's data is never interpreted as code. And of course I don't hardcode secrets: they go in environment variables and outside version control."

Quick challenge

A colleague configured CORS like this in production and also left the key in the code:

app.use(cors({ origin: '*' }));
const dbPassword = 'superSecreta123';

Name two security problems and how you would fix them.

See answer

Problem 1: origin: '*' lets any website call the API from the browser. In production you must restrict it to the trusted origins:

app.use(cors({ origin: 'https://mi-frontend.com' }));

Problem 2: the password is hardcoded in the code. Anyone with access to the repository sees it, and it stays in the Git history. It must be read from an environment variable:

const dbPassword = process.env.DB_PASSWORD;

(The real value is defined outside the code, for example in a .env file ignored by Git or in the server's configuration.)