I hardened my first Express auth API: Helmet, CORS and rate limiting
DEV Community

I hardened my first Express auth API: Helmet, CORS and rate limiting

I started auth-api to stop following tutorials and build something of my own. It's a small authentication API (register, login and a protected route) built with Node, Express, Drizzle ORM and MySQL.

For a while I only cared that it worked. Routes returned JSON, users got saved, great. Then I started wondering what happens when someone points a script at it. So I spent a few days adding the basic security layers, and this post is what I did and what I learned.

It's not finished. I'll tell you what's still missing at the end. Repo: github.com/Fredericrypto/auth-api

Validation and password hashing

Before any network stuff, I wanted two things: never trust the request body, and never store a password in a way I'd be embarrassed about.

Validation is Zod. Every body goes through a schema before it touches the database:

import { z } from 'zod';
const registerSchema = z.object({
  email: z.string().email(),
  senha: z.string().min(6),
  nome: z.string().min(2),
});

(Yes, Portuguese field names. I'm Brazilian.) A 6 character minimum is also low, and I plan to raise it.

For passwords I used Argon2 instead of bcrypt. It's the more modern option and it's harder to crack with GPUs. I'm not a cryptographer, I'm following the OWASP recommendation.

Helmet

By default Express announces itself with X-Powered-By: Express. Helmet removes that and sets a bunch of security headers:

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

I checked the headers with curl:

curl -i -X POST http://localhost:3000/login
HTTP/1.1 400 Bad Request
RateLimit-Limit: 15
Content-Security-Policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;...
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: no-referrer

I sent an empty body on purpose, so Zod rejected it with a 400. That's validation working. What I cared about here are the headers: Helmet is on, and X-Powered-By: Express is gone.

CORS: no more wildcard

app.use(cors()) allows every origin. I replaced it with a whitelist of the origins I actually use, which are just my local frontend ports for now:

const allowedOrigins = ['http://localhost:3000', 'http://localhost:5173'];
app.use(cors({
  origin(origin, callback) {
    // no origin = curl, Postman, mobile apps
    if (!origin || allowedOrigins.includes(origin)) {
      return callback(null, true);
    }
    callback(new Error('Bloqueado pela política de CORS'));
  },
  credentials: true,
}));

Allowed origin:

curl -i -X POST http://localhost:3000/login \
  -H "Origin: http://localhost:5173"
HTTP/1.1 400 Bad Request
Access-Control-Allow-Origin: http://localhost:5173
Vary: Origin
Access-Control-Allow-Credentials: true

Blocked origin:

curl -i -X OPTIONS http://localhost:3000/login \
  -H "Origin: http://evil.com" \
  -H "Access-Control-Request-Method: POST"
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{"sucesso":false,"erro":"Origem não permitida"}

My first version had two bugs. The blocked request returned a 500 with a full stack trace, including file paths from my machine. And I had registered the CORS middleware after my routes, so real responses didn't get CORS headers at all.

Express runs middleware in order, so I moved CORS above the routes and added an error handler (after all routes) that returns a clean 403:

app.use((err, req, res, next) => {
  if (err.message === 'Bloqueado pela política de CORS') {
    return res.status(403).json({ sucesso: false, erro: 'Origem não permitida' });
  }
  console.error(err);
  return res.status(500).json({ sucesso: false, erro: 'Erro interno' });
});

One thing I had to learn: CORS is enforced by the browser. It stops other websites from reading your API's responses in a user's browser. It doesn't stop someone from calling your API with curl, so it's one layer, not a replacement for auth or rate limiting.

Rate limiting

Login is the obvious target for brute force and credential stuffing. I limited it to 15 failed attempts per IP every 15 minutes:

import { rateLimit } from 'express-rate-limit';
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 15,
  skipSuccessfulRequests: true, // only failed attempts count
  standardHeaders: true,
  legacyHeaders: false,
  message: { erro: 'Muitas tentativas de acesso a partir desse IP. Tente novamente após 15 minutos.' },
});
app.post('/login', authLimiter, loginHandler);

There's also a global limiter of 100 requests per IP every 15 minutes for the whole API.

I tested the login limit by sending requests in a loop:

for i in {1..16}; do curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/login done
400 (x14)
429
429

I had already used one request before running the loop, so 14 went through and the rest got a 429 Too Many Requests. Per-IP limiting has its own problems (people behind the same NAT share an IP), so Redis and smarter limits are on my list.

What's still missing

  • Refresh tokens and logout
  • Better error handling and logging (Winston/Morgan)
  • Redis for rate limiting
  • Tests. There are none yet. I know.

Why I'm posting this now

I learn faster when I write things down and someone can tell me I'm wrong. If you spot something dumb in the repo, open an issue, I MEAN IT.

Next post will probably be about JWT and protected routes, and what I got wrong the first time.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.