Rate Limiting an API Nobody Is Attacking Yet
express-rate-limit and Helmet went into Fishing Tracker Pro's backend before there was any evidence of abuse — because CodeQL found six unrated routes before an attacker did.
A fishing log app for a few hundred users in Mauritius is not a plausible DDoS target. It is, however, exactly the kind of small, low-traffic API that gets scraped by credential-stuffing bots and vulnerability scanners as a matter of routine internet background noise, entirely independent of whether anyone specifically cares about fishing logs. express-rate-limit v7.4.1 and helmet v8.0.0 are both in backend/package.json for that reason, not because of an actual incident.
Six unrated routes, six CodeQL alerts, one middleware that should have covered all of them from the start.
The routes that actually need different limits
Not every endpoint deserves the same limit. /auth/login and /auth/register are the ones worth being stingy with, because they're the ones a credential-stuffing script actually hammers:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: 'Too many login attempts, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
router.post('/login', authLimiter, authController.login);
router.post('/register', authLimiter, authController.register);Ten attempts per fifteen minutes doesn't meaningfully inconvenience a real user who mistyped their password twice; it does meaningfully slow down an automated attempt to try a leaked password list against every registered email. General API routes — /trips, /recommendations — get a much looser limit, because the goal there isn't blocking abuse so much as protecting the server (and the WeatherAPI/WorldTides quota behind it) from a runaway client loop:
const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 300 });
app.use('/api/', apiLimiter);Helmet is the five-minute version of this same idea
Where rate limiting protects against volume, Helmet protects against a handful of well-known header-level mistakes — missing X-Content-Type-Options, missing Content-Security-Policy defaults, X-Powered-By: Express advertising the framework version to anyone curious. It's one line:
const helmet = require('helmet');
app.use(helmet());There's no tuning story here worth telling, which is precisely the point. Helmet's defaults are good enough that the interesting engineering decision is remembering to add it, not configuring it.
Why "nobody is attacking yet" is the wrong bar
The honest framing for a solo project isn't "we added this because we got attacked" — we didn't. It's "we added this because CodeQL's rate-limiting alerts (six of them, detailed in the previous post) made it obvious that not having a default was the actual bug, not any single missing route." A scanner doesn't wait for an incident to flag a gap; it flags the gap the moment the code exists. Waiting for evidence of exploitation before hardening an API that's already public is optimizing for the wrong signal — the cost of adding express-rate-limit is a few lines and negligible latency; the cost of not having it is unknown and asymmetric.
What this doesn't protect against
Rate limiting per-IP is trivially bypassed by anyone with access to a residential proxy pool, and Helmet's headers do nothing against a compromised dependency or a logic bug in the auth flow. Both are still worth having, the same way a lock on a front door is worth having even though it won't stop someone determined to break a window — it raises the cost of the cheap, common attack, which for a solo-maintained side project is most of what actually shows up in the logs.
Series: Fishing Tracker Pro. Next: why the database has no exposed port at all — Traefik labels instead of published ports.