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.
Comments
No comments yet. Start the discussion.