The Best Way to Write Controllers and Middlewares to Secure an API

May 27, 2026
Updated 11 hours ago
5 min read

The Best Way to Write Controllers and Middlewares to Secure an API

APIs are the backbone of modern applications. Whether you’re building a SaaS platform, a mobile backend, or a public developer API, security should never be treated as an afterthought.

One of the most effective ways to secure an API is by designing clean controllers and reusable middlewares. A well-structured API not only becomes easier to maintain but also significantly reduces vulnerabilities caused by inconsistent validation, poor authentication flows, and duplicated logic.

In this post, you’ll learn the best practices for writing secure controllers and middlewares, with practical examples and architectural guidance.


Why Controllers and Middlewares Matter

Before diving into implementation, it’s important to understand their responsibilities.

Controllers

Controllers should focus only on:

  • Handling requests

  • Calling business logic/services

  • Returning responses

They should not contain:

  • Authentication logic

  • Validation rules

  • Rate limiting

  • Permission checks

  • Error formatting

Middlewares

Middlewares act as security gates before requests reach your controllers.

They are ideal for:

  • Authentication

  • Authorization

  • Input validation

  • Request sanitization

  • Logging

  • Rate limiting

  • IP filtering

  • Security headers

A secure API architecture separates concerns cleanly.


The Biggest Mistake Developers Make

A common anti-pattern looks like this:

js
app.post('/users', async (req, res) => {
  // validation
  // auth
  // role checks
  // database logic
  // error handling
  // response formatting
})

This creates:

  • Bloated endpoints

  • Security inconsistencies

  • Difficult testing

  • Repeated code

  • Hidden vulnerabilities

Instead, use layered middleware pipelines.


The Ideal API Request Flow

A secure request lifecycle should look like this:

txt
Request
   ↓
Security Headers Middleware
   ↓
Rate Limiter Middleware
   ↓
Authentication Middleware
   ↓
Authorization Middleware
   ↓
Validation Middleware
   ↓
Controller
   ↓
Response

This keeps controllers clean and predictable.


1. Keep Controllers Thin

Controllers should never directly contain business rules or security logic.

Bad Example

js
const createUser = async (req, res) => {
  const token = req.headers.authorization

  if (!token) {
    return res.status(401).json({ message: 'Unauthorized' })
  }

  if (!req.body.email) {
    return res.status(400).json({ message: 'Email required' })
  }

  const user = await User.create(req.body)

  res.json(user)
}

This mixes too many responsibilities.


Good Example

js
router.post(
  '/users',
  authenticate,
  validate(createUserSchema),
  userController.create
)

Controller:

js
const create = async (req, res) => {
  const user = await userService.create(req.body)

  res.status(201).json({
    success: true,
    data: user
  })
}

Benefits:

  • Easier testing

  • Better readability

  • Centralized security

  • Reusable middleware logic


2. Centralize Authentication in Middleware

Authentication should happen before controllers execute.

JWT Authentication Middleware

js
const jwt = require('jsonwebtoken')

const authenticate = (req, res, next) => {
  try {
    const token = req.headers.authorization?.split(' ')[1]

    if (!token) {
      return res.status(401).json({
        message: 'Authentication required'
      })
    }

    const decoded = jwt.verify(token, process.env.JWT_SECRET)

    req.user = decoded

    next()
  } catch (err) {
    return res.status(401).json({
      message: 'Invalid token'
    })
  }
}

Why This Matters

Centralized auth middleware:

  • Prevents duplicated logic

  • Standardizes token validation

  • Reduces security gaps

  • Makes auditing easier


3. Use Authorization Middleware for Role-Based Access

Authentication tells you who the user is.

Authorization determines what they can do.

Example

js
const authorize = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        message: 'Forbidden'
      })
    }

    next()
  }
}

Usage:

js
router.delete(
  '/users/:id',
  authenticate,
  authorize('admin'),
  userController.delete
)

This avoids hardcoding role checks inside controllers.


4. Validate Every Input

Never trust client input.

Validation should happen before reaching business logic.

Use Schema Validation

Libraries commonly used:

  • Joi

  • Zod

  • Yup

  • express-validator

Example Validation Middleware

js
const validate = (schema) => {
  return (req, res, next) => {
    const { error } = schema.validate(req.body)

    if (error) {
      return res.status(400).json({
        message: error.details[0].message
      })
    }

    next()
  }
}

Why Validation Matters

Without validation, attackers can:

  • Inject malicious payloads

  • Crash services

  • Bypass logic

  • Trigger unexpected database behavior

Validation is one of the simplest and strongest defenses.


5. Sanitize Input Data

Validation checks structure.

Sanitization removes dangerous content.

Examples:

  • Trim whitespace

  • Escape HTML

  • Remove unexpected fields

  • Normalize emails

  • Prevent NoSQL injection

Example

js
req.body.email = req.body.email.trim().toLowerCase()

Never pass raw request payloads directly into database queries.


6. Use Rate Limiting Middleware

Rate limiting protects against:

  • Brute-force attacks

  • Credential stuffing

  • DDoS abuse

  • API scraping

Example

js
const rateLimit = require('express-rate-limit')

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100
})

app.use('/api', limiter)

Apply stricter limits to:

  • Login routes

  • Password reset endpoints

  • OTP verification APIs


7. Add Security Headers

Security headers reduce common browser-based attacks.

If you use Node.js with Express, middleware like Helmet helps.

Example

js
const helmet = require('helmet')

app.use(helmet())

This protects against:

  • Clickjacking

  • MIME sniffing

  • XSS attacks

  • Unsafe resource loading


8. Never Leak Internal Errors

Bad error handling exposes sensitive details.

Dangerous

js
res.status(500).json({
  error
})

This may leak:

  • Stack traces

  • Database structure

  • Internal paths

  • Secrets


Better

js
res.status(500).json({
  message: 'Internal server error'
})

Log detailed errors internally instead.


9. Use a Global Error Middleware

Centralized error handling improves consistency.

Example

js
const errorHandler = (err, req, res, next) => {
  console.error(err)

  res.status(err.status || 500).json({
    success: false,
    message: err.message || 'Something went wrong'
  })
}

Usage:

js
app.use(errorHandler)

Benefits:

  • Cleaner controllers

  • Consistent responses

  • Easier debugging

  • Better observability


10. Never Trust Middleware Order by Accident

Middleware order matters.

Incorrect ordering can expose protected routes.

Wrong

js
router.get(
  '/admin',
  adminController.dashboard,
  authenticate
)

Correct

js
router.get(
  '/admin',
  authenticate,
  authorize('admin'),
  adminController.dashboard
)

Security middleware should always execute first.


11. Separate Business Logic into Services

Controllers should orchestrate.

Services should implement business rules.

Controller

js
const create = async (req, res) => {
  const user = await userService.create(req.body)

  res.json(user)
}

Service

js
const create = async (payload) => {
  const exists = await User.findOne({
    email: payload.email
  })

  if (exists) {
    throw new Error('User already exists')
  }

  return User.create(payload)
}

This separation improves:

  • Testability

  • Security reviews

  • Code reuse

  • Maintainability


12. Protect Sensitive Routes with Extra Layers

High-risk endpoints should include:

  • MFA verification

  • Device checks

  • IP restrictions

  • Audit logging

  • Short-lived tokens

Examples:

  • Payment APIs

  • Admin endpoints

  • Account recovery flows

Not all routes require the same security level.


13. Log Security Events

You should log:

  • Failed logins

  • Permission denials

  • Suspicious requests

  • Token failures

  • Rate limit violations

Avoid logging:

  • Passwords

  • Tokens

  • Sensitive PII

Good logs help detect attacks early.


A scalable API structure might look like this:

txt
src/
├── controllers/
├── middlewares/
├── services/
├── routes/
├── validators/
├── utils/
├── config/
└── models/

This organization keeps security concerns centralized and maintainable.


Example of a Clean Secure Route

js
router.post(
  '/payments',
  authenticate,
  authorize('user'),
  rateLimiter,
  validate(paymentSchema),
  paymentController.create
)

Notice how:

  • Security is layered

  • Controllers remain clean

  • Logic is reusable

  • The route becomes self-documenting


Final Thoughts

The best way to secure an API is not by adding random security patches later. It’s by designing a clean architecture from the start.

The key principles are simple:

  • Keep controllers thin

  • Move security into middleware

  • Validate everything

  • Centralize authentication

  • Separate business logic

  • Handle errors consistently

  • Apply layered defenses

When controllers focus only on handling requests and middlewares handle security concerns, your API becomes easier to scale, maintain, audit, and secure.

Clean architecture is security architecture.