Backend Security for Express.js (With Nginx + VPS)
Source: Dev.to
Table of Contents
- Threat Model
- Why Backend Security Is Critical
- Security Baseline
- Project Setup
- Security Headers
- CORS Strategy
- Authentication & Authorization
- Input Validation & Sanitization
- Rate Limiting
- Request Size Limits
- Logging & Audit
- Secrets & Environment Security
- TLS (HTTPS) & Nginx Configuration
- Nginx Rate Limiting
- Basic Bot/Scanner Reduction
- Recommended Security Headers at Nginx
- SSH Hardening
- Firewall (UFW)
- Fail2ban
- Automatic Security Updates
- Deployment Checklist
Threat Model
Typical Attack Vectors for a Public Backend
- Brute‑force login attempts
- API abuse / scraping
- Credential stuffing
- Injection attacks (SQL/NoSQL)
- Misconfigured CORS
- Token theft (JWT, session cookies)
- Vulnerability scanners
- Reverse‑proxy bypass (direct port access)
- Server compromise via weak SSH
Impact Matrix
| Threat | Impact |
|---|---|
| NoSQL Injection | Database compromise |
| Credential Stuffing | Account takeover |
| JWT Theft | Full user impersonation |
| CSRF | Unauthorized actions |
| XSS | Token and session leakage |
| API Abuse | Server and payment exploitation |
The goal is to reduce risk using multiple independent layers.
Security Baseline
| Control | Description |
|---|---|
| HTTPS enforced | All traffic encrypted |
| Strong authentication strategy | JWT or session‑based |
| Strict CORS | Whitelist origins |
| Server‑side validation | Never trust client data |
| Rate limiting | Thwart brute‑force & abuse |
| Centralized logging | Audit trails |
| Nginx reverse‑proxy protection | Hide internal ports |
| Firewall + SSH hardening | Limit surface area |
| Fail2ban + automatic security patches | Reactive & proactive defense |
Project Setup
# Core dependencies
npm i express helmet cors express-rate-limit cookie-parser compression
# Validation, logging, env handling
npm i zod pino pino-http dotenv
Security Headers
Helmet applies a sensible set of HTTP security headers.
import helmet from "helmet";
app.use(helmet());
Note: For pure APIs you can skip an aggressive CSP unless you also serve HTML pages.
CORS Strategy
Never use * in production, especially when cookies or credentials are involved.
import cors from "cors";
const allowedOrigins = [
"https://your-frontend.com",
"https://www.your-frontend.com",
];
app.use(
cors({
origin: (origin, cb) => {
// Allow non‑browser requests (e.g., Postman) or whitelisted origins
if (!origin) return cb(null, true);
if (allowedOrigins.includes(origin)) return cb(null, true);
return cb(new Error("CORS blocked"), false);
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
})
);
Authentication & Authorization
| Concept | Description |
|---|---|
| Authentication | Proves the identity of a client (e.g., login, JWT issuance). |
| Authorization | Determines what the authenticated identity may do (role‑based checks). |
Best practices
- Short‑lived access tokens, rotate refresh tokens.
- Role‑based checks for privileged routes.
// Example role guard
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ status: false, message: "Forbidden" });
}
next();
};
}
Input Validation & Sanitization
Prefer schema validation (e.g., Zod) over ad‑hoc checks.
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(2).max(80),
email: z.string().email(),
password: z.string().min(8).max(72),
});
export function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
status: false,
message: "Validation failed",
errors: result.error.issues,
});
}
req.body = result.data; // use sanitized data downstream
next();
};
}
Usage
app.post("/api/users", validate(createUserSchema), (req, res) => {
// req.body is guaranteed to match the schema
});
Rate Limiting
Apply different limits per endpoint category.
import rateLimit from "express-rate-limit";
export const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 600, // 600 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
});
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20, // stricter for login/registration
message: { message: "Too many login attempts. Try again later." },
});
app.use(globalLimiter); // applies to all routes
app.use("/api/auth", authLimiter); // only auth routes
Request Size Limits
Prevent payload abuse:
app.use(express.json({ limit: "200kb" }));
app.use(express.urlencoded({ extended: true, limit: "200kb" }));
Logging & Audit
Structured logging makes analysis easier.
import pino from "pino";
import pinoHttp from "pino-http";
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
app.use(pinoHttp({ logger }));
Log (but never log passwords, tokens, or raw secrets):
- Authentication failures
- Suspicious traffic spikes
- Rate‑limit blocks
Secrets & Environment Security
- Store secrets in
.envfor development and in the server’s environment variables for production. - Never commit
.envto version control. - Rotate compromised keys immediately.
# .env (example)
NODE_ENV=production
PORT=3000
JWT_SECRET=YOUR-JWT-SECRET-KEY
TLS (HTTPS) & Nginx Reverse Proxy
Architecture
Internet → Nginx (443) → Express (127.0.0.1:8000)
Install Certbot & Obtain a Certificate
sudo apt update
sudo apt install nginx certbot python3-certbot-nginx -y
# Issue a certificate for your API sub‑domain
sudo certbot --nginx -d api.yourdomain.com
# Verify auto‑renewal timer
sudo systemctl status certbot.timer
Nginx Rate Limiting
Add the following to /etc/nginx/nginx.conf (inside the http {} block):
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
Example Server Block
server {
listen 443 ssl http2;
server_name api.yourdomain.com;
# TLS (handled by Certbot)
ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;
# Rate limiting
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 20;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
}
Basic Bot/Scanner Reduction
Block common junk requests at the Nginx level.
# Block WordPress xmlrpc pingbacks (if you don't run WP)
location = /xmlrpc {
deny all;
}
Add more patterns as needed (e.g., /.env, /admin, known scanner user‑agents).
Recommended Security Headers at Nginx
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Permissions-Policy "geolocation=(), microphone=()" always;
# (CSP can be added if you serve HTML)
SSH Hardening
# Disable root login
sudo sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
# Use key‑based auth only
sudo sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
# Change default port (optional)
# sudo sed -i 's/#Port 22/Port 2222/' /etc/ssh/sshd_config
sudo systemctl restart sshd
- Keep your private key safe.
- Use a passphrase and an SSH agent.
Firewall (UFW)
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH (adjust port if you changed it)
sudo ufw allow 22/tcp # or 2222/tcp if you changed the port
# Allow HTTP/HTTPS (handled by Nginx)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Fail2ban
sudo apt install fail2ban -y
# Basic jail for SSH
cat <<EOF > /etc/fail2ban/jail.local
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
maxretry = 5
EOF
Never trust the frontend. Trust only the backend and verify everything.
Automatic Security Updates
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
Deployment Checklist
- HTTPS enforced via Nginx + Certbot
- Helmet security headers applied
- Strict CORS whitelist
- Input validation with Zod (or similar)
- Rate limiting (Express & Nginx)
- Request size limits configured
- Structured logging (pino)
- Secrets stored securely, not in VCS
- SSH key‑only authentication, root disabled
- UFW firewall rules applied
- Fail2ban active for SSH (and optionally Nginx)
- Automatic security updates enabled
Final Thoughts
Express.js backend security is a discipline rather than a single configuration or module. Strong application‑level restrictions, a hardened reverse proxy, and a properly locked‑down server environment are the components of real‑world security.
By implementing tiered defenses with Express, Nginx, and VPS‑level hardening, you can greatly limit the attack surface of your API and safeguard your users, data, and business logic from real‑world threats.
For long‑term production use, a secure backend is not only safer but also more dependable, scalable, and trustworthy.