Backend Security for Express.js (With Nginx + VPS)

Published: (January 12, 2026 at 12:47 AM EST)
6 min read
Source: Dev.to

Source: Dev.to

Table of Contents

  1. Threat Model
  2. Why Backend Security Is Critical
  3. Security Baseline
  4. Project Setup
  5. Security Headers
  6. CORS Strategy
  7. Authentication & Authorization
  8. Input Validation & Sanitization
  9. Rate Limiting
  10. Request Size Limits
  11. Logging & Audit
  12. Secrets & Environment Security
  13. TLS (HTTPS) & Nginx Configuration
  14. Nginx Rate Limiting
  15. Basic Bot/Scanner Reduction
  16. Recommended Security Headers at Nginx
  17. SSH Hardening
  18. Firewall (UFW)
  19. Fail2ban
  20. Automatic Security Updates
  21. 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

ThreatImpact
NoSQL InjectionDatabase compromise
Credential StuffingAccount takeover
JWT TheftFull user impersonation
CSRFUnauthorized actions
XSSToken and session leakage
API AbuseServer and payment exploitation

The goal is to reduce risk using multiple independent layers.

Security Baseline

ControlDescription
HTTPS enforcedAll traffic encrypted
Strong authentication strategyJWT or session‑based
Strict CORSWhitelist origins
Server‑side validationNever trust client data
Rate limitingThwart brute‑force & abuse
Centralized loggingAudit trails
Nginx reverse‑proxy protectionHide internal ports
Firewall + SSH hardeningLimit surface area
Fail2ban + automatic security patchesReactive & 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

ConceptDescription
AuthenticationProves the identity of a client (e.g., login, JWT issuance).
AuthorizationDetermines 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 .env for development and in the server’s environment variables for production.
  • Never commit .env to 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).

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.

Back to Blog

Related posts

Read more »

Hello, Newbie Here.

Hi! I'm falling back into the realm of S.T.E.M. I enjoy learning about energy systems, science, technology, engineering, and math as well. One of the projects I...