The Best Way to Write Controllers and Middlewares to Secure an API
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:
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:
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
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
router.post(
'/users',
authenticate,
validate(createUserSchema),
userController.create
)
Controller:
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
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
const authorize = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
message: 'Forbidden'
})
}
next()
}
}
Usage:
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
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
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
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
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
res.status(500).json({
error
})
This may leak:
Stack traces
Database structure
Internal paths
Secrets
Better
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
const errorHandler = (err, req, res, next) => {
console.error(err)
res.status(err.status || 500).json({
success: false,
message: err.message || 'Something went wrong'
})
}
Usage:
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
router.get(
'/admin',
adminController.dashboard,
authenticate
)
Correct
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
const create = async (req, res) => {
const user = await userService.create(req.body)
res.json(user)
}
Service
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.
Recommended Folder Structure
A scalable API structure might look like this:
src/
├── controllers/
├── middlewares/
├── services/
├── routes/
├── validators/
├── utils/
├── config/
└── models/
This organization keeps security concerns centralized and maintainable.
Example of a Clean Secure Route
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.
