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
First: 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.



