Draft, not yet published
Rate limiting, CORS, and debug mode: the 20-minute hardening pass for AI-generated backends
7 July 2026· 8 min read · by Stackbastion
Your AI-built backend works, but it was generated to run, not to survive the open internet. Three things are almost certainly wrong right now: there’s no rate limit, so one script can hammer any endpoint; CORS is wide open, so any website can call your API; and debug mode is on, so a crash spills your stack trace and config to whoever triggered it. Each is a one-config fix. Together they’re about 20 minutes, and they close the holes attackers check first.
Why AI-generated backends ship with these holes
AI coding tools write the code that makes a feature work. Rate limiting, CORS, and debug settings aren’t features you asked for, so they get whatever default makes the demo run smoothly. That default is almost always the permissive one.
- No rate limiting, because a demo has one user and limits would just get in the way.
- CORS set to allow everything (
*), because the fastest way to make the frontend talk to the backend during development is to allow all origins and never think about it again. - Debug mode on, because verbose errors help while you’re building, and nobody flips them off before shipping.
None of these break anything, so they survive to production. And each is exactly what an attacker probes for, because they’re common and they’re cheap to exploit. Let’s fix all three. The examples are Node/Express, but the same three settings exist in every framework.
Fix 1: rate limiting (stop one client from abusing every endpoint)
Without a rate limit, a single client can send thousands of requests a second. That’s how people brute-force login passwords, scrape your whole database through a paginated endpoint, or run up your costs by spamming an endpoint that calls a paid AI API. One bad actor becomes an outage or a bill for everyone.
The fix is a per-client cap: this IP gets at most N requests per window, and past that it gets a 429 Too Many Requests. In Express, express-rate-limit does it in a few lines:
npm install express-rate-limit
import rateLimit from "express-rate-limit";
// General limit for the whole API
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 300, // 300 requests per IP per window
standardHeaders: true, // send RateLimit-* headers
legacyHeaders: false,
message: { error: "Too many requests, slow down." },
});
app.use("/api/", apiLimiter);
That’s a sane default for general traffic. But the endpoints that actually get attacked (login, signup, password reset, anything that sends an email or costs money) need a much tighter limit. Put a strict limiter on just those:
// Strict limit for sensitive endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // only 5 attempts per IP per 15 min
message: { error: "Too many attempts. Try again later." },
});
app.use("/api/login", authLimiter);
app.use("/api/signup", authLimiter);
app.use("/api/reset-password", authLimiter);
Five login attempts per 15 minutes stops password brute-forcing cold, while 300 general requests keeps real users comfortable. One gotcha: if your app runs behind a proxy or load balancer (most do), tell Express to trust it so the limiter sees the real client IP instead of the proxy’s:
app.set("trust proxy", 1); // required behind a reverse proxy for correct IPs
Skip that and every request looks like it comes from one IP (the proxy), so your limiter either blocks everyone or nobody.
Fix 2: CORS (stop any website from using your API as its own)
CORS decides which websites’ JavaScript is allowed to call your API from a browser. The generated default is usually “allow any origin,” written as * or by reflecting whatever origin asks. That means a random site, or a phishing page, can make a user’s browser call your API and use their logged-in session. Wide-open CORS combined with cookie auth is a real path to stolen data.
The fix is an allowlist: only your own frontend’s domain(s) may call the API. Everything else gets refused.
npm install cors
import cors from "cors";
const allowedOrigins = [
"https://your-app.com",
"https://www.your-app.com",
];
app.use(
cors({
origin(origin, callback) {
// allow same-origin / server-to-server (no Origin header)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error("Not allowed by CORS"));
},
credentials: true, // needed if you send cookies
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
}),
);
Two things people get wrong here. First, never combine origin: "*" with credentials: true. Browsers actually block that combination, and trying to work around it by reflecting every origin recreates the exact hole you’re closing. Use a real allowlist. Second, remember to include every domain your frontend really uses: your production domain, the www version, and any staging or preview URL. Miss one and your own app gets CORS errors, which is annoying but tells you the allowlist is working.
If you keep the local dev origin in the list, load it from an environment variable and only add it outside production, so http://localhost:3000 never ships as an allowed origin in your live app:
if (process.env.NODE_ENV !== "production") {
allowedOrigins.push("http://localhost:3000");
}
Fix 3: debug mode (stop leaking your internals on every error)
Debug mode makes errors verbose. In development that’s helpful: you get the full stack trace, file paths, sometimes the query that failed or the config that’s set. In production, that same verbose error goes to whoever triggered the crash, and it’s a gift to an attacker. A stack trace reveals your framework and versions (so they know which exploits to try), your file structure, and sometimes connection strings or keys sitting in the error context.
Two parts to this fix.
First, actually set the production environment flag. Most frameworks change their error behavior based on it, and running “in production” without setting it means you’re still in dev mode:
# On your server / in your deploy environment
NODE_ENV=production
Second, add an error handler that logs the detail server-side but sends the user nothing useful. The full error goes to your logs where you can read it. The client gets a generic message and a status code, no internals:
// Last middleware: catch-all error handler
app.use((err, _req, res, _next) => {
// Full detail stays in your logs, never in the response
console.error(err);
const isProd = process.env.NODE_ENV === "production";
res.status(err.status || 500).json({
error: isProd ? "Something went wrong." : err.message,
// only include the stack outside production
...(isProd ? {} : { stack: err.stack }),
});
});
Now a crash in production returns {"error":"Something went wrong."} to the user while you get the whole stack trace in your logs. You lose nothing for debugging and the attacker learns nothing about your system.
While you’re here, check two related leaks that ride along with debug mode. Turn off any framework banner that advertises your stack (app.disable("x-powered-by") in Express removes the “I’m running Express” header), and make sure your API doesn’t return raw database errors, which often contain table and column names straight from your schema.
Put it together
Three middlewares, in this order near the top of your app:
app.set("trust proxy", 1);
app.disable("x-powered-by");
app.use(cors({ /* allowlist config above */ }));
app.use("/api/", apiLimiter);
app.use("/api/login", authLimiter);
// ... your routes ...
app.use(errorHandler); // the catch-all, added last
Twenty minutes, three config changes, and you’ve closed the holes that automated attackers scan for on every new backend. None of this changes what your app does for real users. It just stops it from being an easy target.
Or, we do it for you
A production audit runs this exact hardening pass against your live backend (rate limits on the endpoints that need them, a real CORS allowlist, debug mode off, no internals leaking on error) and reports anything else exposed. If you want a second pair of eyes on your setup regardless of what you built it with, get a free production audit.
FAQ
What rate limit should I actually use?
Two tiers. A general limit around 300 requests per IP per 15 minutes keeps normal users comfortable while stopping abuse. A strict limit of about 5 per 15 minutes on sensitive endpoints (login, signup, password reset) stops brute-forcing and email spam. Tune the numbers to your real traffic, but always make the auth endpoints much tighter than the general API.
My frontend suddenly gets CORS errors after I locked it down. Did I break it?
Probably not, you just missed a domain in the allowlist. Add every origin your frontend really loads from: the production domain, the www variant, and any staging or preview URL. The error is CORS doing its job. Check the exact origin in the browser’s error message and add it if it’s genuinely yours.
How do I know if debug mode is on in production?
Trigger an error on your live app (hit a broken route, submit bad data) and look at the response. If you see a stack trace, file paths, or framework details, debug mode is on. A hardened app returns a generic message and a status code, with the real detail only in your server logs. Also confirm NODE_ENV=production is actually set in your deploy environment.
Is CORS the same as authentication?
No, and this trips people up. CORS controls which websites’ browser code may call your API. It does nothing to verify who the user is. You still need real authentication (sessions, tokens) to control access. CORS is one layer; think of it as who’s allowed to knock, not who’s allowed in. Lock down both.