Step 1: security headers with Helmet.js

Install helmet and apply it as the first middleware in your stack. Helmet sets a collection of HTTP headers that mitigate common web vulnerabilities: X-Content-Type-Options, Strict-Transport-Security, X-Frame-Options, and Content Security Policy among others.

import helmet from 'helmet';
app.use(helmet());

For APIs that don't serve HTML, tighten CSP further: helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'none'"] } } }). Don't skip this because "it's just an API." Response headers prevent response sniffing, clickjacking on error pages, and MIME confusion attacks that can chain with other vulnerabilities.

Step 2: rate limiting

Without rate limiting, your login endpoint is a brute-force target, your OTP verification is bypassable through enumeration, and your payment initiation endpoint is open to abuse. Use express-rate-limit with per-endpoint configuration:

import rateLimit from 'express-rate-limit';

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // 10 attempts per window
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many attempts, try again later' }
});

app.use('/api/auth/login', authLimiter);

Apply stricter limits to authentication, OTP, and password reset endpoints. Apply looser limits to read-only data endpoints. For a deeper dive into rate limiting patterns for payment APIs, see our rate limiting and anti-fraud guide.

Step 3: input validation with Zod

Never trust client input. Every request body, query parameter, and URL parameter must be validated and typed before your business logic touches it. Zod provides runtime type validation with excellent TypeScript integration:

import { z } from 'zod';

const transferSchema = z.object({
  amount: z.number().positive().max(10_000_000),
  recipientAccount: z.string().regex(/^\d{10}$/),
  bankCode: z.string().length(3),
  narration: z.string().max(100).optional()
});

app.post('/api/transfers', (req, res, next) => {
  const result = transferSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ errors: result.error.issues });
  }
  // Use result.data — fully typed and validated
});

Joi is a solid alternative if you're already using it. The library matters less than the discipline: validate every input on every endpoint. Client-side validation is a UX feature, not a security control. See fintech API security steps for the full context.

Pentest finding

Missing server-side validation on transfer amounts

In a recent assessment of a Nigerian lending platform's Express API, the transfer endpoint validated amounts client-side but accepted arbitrary values server-side. We submitted negative amounts that reversed the transaction direction, crediting our test account instead of debiting it.

Step 4: parameterized queries

SQL injection remains a top vulnerability because developers still concatenate user input into query strings. If you're using raw SQL with pg, mysql2, or better-sqlite3, always use parameterized queries:

// WRONG — SQL injection vulnerability
const result = await pool.query(
  `SELECT * FROM users WHERE email = '${req.body.email}'`
);

// CORRECT — parameterized query
const result = await pool.query(
  'SELECT * FROM users WHERE email = $1',
  [req.body.email]
);

If you use an ORM like Prisma, Sequelize, or Drizzle, parameterization is handled automatically for standard queries. But watch out for raw query methods (prisma.$queryRaw, sequelize.query) — these require manual parameterization and are frequent sources of injection when developers bypass the ORM for complex queries.

Step 5: CORS configuration

The default CORS middleware configuration of origin: '*' allows any website to make authenticated requests to your API if you're using cookies or session-based auth. Restrict it to your actual frontend origins:

import cors from 'cors';

app.use(cors({
  origin: ['https://app.yourfintech.com', 'https://admin.yourfintech.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}));

For APIs consumed exclusively by mobile apps or server-to-server, CORS is less critical since browsers enforce it, not HTTP clients. But configure it properly anyway — you don't want to discover a web client is hitting your API directly when an attacker exploits the open CORS policy.

Step 6: authentication middleware

Apply auth middleware globally, then explicitly whitelist public routes. This is safer than the reverse (applying auth per-route) because a forgotten middleware call on a new route creates an unauthenticated endpoint:

// Apply globally
app.use('/api', authenticateToken);

// Whitelist specific public routes BEFORE the global middleware
app.post('/api/auth/login', loginHandler);
app.post('/api/auth/register', registerHandler);

For JWT-based auth, validate the signature, check expiration, verify the issuer and audience claims, and confirm the token hasn't been revoked. See our detailed breakdown of JWT security mistakes for common pitfalls. For session-based auth, use express-session with a secure store (Redis, not the default in-memory store) and set httpOnly, secure, and sameSite cookie flags.

Step 7: error handling that doesn't leak

Express's default error handler sends stack traces to the client in development mode. In production, unhandled errors should return a generic message, log the details server-side, and never expose internal paths, database schemas, or library versions:

app.use((err, req, res, next) => {
  console.error(err); // Log the full error internally
  res.status(err.status || 500).json({
    error: 'An unexpected error occurred'
    // Never: message: err.message, stack: err.stack
  });
});

Set NODE_ENV=production in production. Several Express libraries change their behavior based on this variable, suppressing verbose error output.

Want your Express API tested for these vulnerabilities and more? We specialise in API security assessments for Nigerian fintechs.

Book an API security audit

Step 8: dependency auditing

The Express ecosystem is built on npm packages, and npm packages have vulnerabilities. Run npm audit or pnpm audit regularly. Integrate snyk or socket.dev into your CI pipeline to catch known vulnerabilities and supply chain attacks before they reach production.

Pin your dependency versions. Use lockfiles. Review new dependencies before adding them — check download counts, maintenance activity, and whether the package does what it claims without suspicious network calls or filesystem access.

Key takeaway

Express security is additive

Express ships as a minimal framework by design. Security is your responsibility at every layer: headers, rate limiting, validation, queries, CORS, authentication, error handling, and dependencies. Miss any one of these, and you've given an attacker a foothold. Apply them all, and you have a defensible API.

Related reading

Blog: Secure your fintech API · Rate limiting for payment APIs · JWT security mistakes

Guides: OWASP for fintech · Web app pentesting · Security checklist

Services: API security · Penetration testing · Authentication security