Six Security Holes AI-Generated Apps Ship With — A Pre-Deploy Review
An LLM writes code for the situation it can see: your laptop, one user, no adversary. Deploy it and the assumptions change completely — every port you expose is reachable from the internet, and the edge only terminates TLS and routes by hostname. No web application firewall, no rate limiting, no IP allowlist. Whatever the model wrote is the entire defence. That's fine, because the gaps are predictable and the same six show up again and again. Each is a two-minute check, and none of them require you to be a security engineer.
1. Debug mode left on
The most common one, and the highest-impact. Development settings get carried into the deployed configuration because that's what worked locally.
Django's documentation is unambiguous: "Never deploy a site into production with DEBUG turned on" (Django docs). With it on, an exception renders a page containing detailed tracebacks, your settings, and your SQL. Django filters names containing KEY, PASS, SECRET, TOKEN and similar — but as the docs note, "there are always going to be sections of your debug output that are inappropriate for public consumption. File paths, configuration options and the like all give attackers extra information about your server."
There's a hosting consequence too: in debug mode Django "remembers every SQL query it executes, which can rapidly consume memory on a production server." On a small container, DEBUG=True is both a disclosure and a slow memory leak.
The same applies to Flask's debugger, Express's stack-trace error handler, and any framework's verbose error page.
Check: grep your config for DEBUG, debug=True, NODE_ENV. Set them as environment variables rather than in code, so production and local differ by configuration.
If you're on Django, remember ALLOWED_HOSTS becomes mandatory once DEBUG is False — "failing to do so will result in all requests being returned as 'Bad Request (400)'". That trips people up right after they fix the first problem.
2. Write endpoints with no authentication
Ask for "an API for managing posts" and you get create, update, and delete routes. Authentication is a separate feature, and unless you asked for it, it isn't there.
Locally this is invisible — you're the only client. Deployed, DELETE /api/posts/1 works for anyone who tries it.
Check: list every route that writes, and confirm each has an auth check. If your framework supports a dependency or middleware, apply it at the router level rather than per-route, so a new endpoint is protected by default rather than by memory.
Admin interfaces deserve special attention. An /admin route that "isn't linked from anywhere" is found by scanners within days.
3. CORS set to *
Models add permissive CORS to make the browser error go away, which it does.
Two things to know. A wildcard origin with credentials is not a valid combination — browsers reject it — so this breaks the moment you add real auth. And more importantly, CORS is not a security control. It's a browser policy. Your API is equally reachable from curl regardless of what you set.
Check: replace * with your actual frontend origin, and don't treat it as protection. If an endpoint needs restricting, it needs authentication. More on splitting a frontend and backend.
4. SQL built with string interpolation
Ask for "a query that finds users by name" and you'll sometimes get an f-string. It works, it reads naturally, and it's injectable.
# generated, and wrong
cur.execute(f"SELECT * FROM users WHERE name = '{name}'")
# parameterised
cur.execute("SELECT * FROM users WHERE name = %s", (name,))
Check: grep for execute( alongside f-strings, +, %, or template literals. Every one of those is either a bug or a deliberate decision you should be able to explain. ORMs are safe by default until someone reaches for raw SQL.
5. No limit on anything expensive
The one that's specific to this era, and the one that costs actual money.
If your app has an endpoint that calls a model API, every request spends your credit. Deployed publicly with no rate limit, one enthusiastic user — or one bored one — can run up a bill while your app behaves exactly as designed. There's no platform-level rate limiting to fall back on, so it has to be in your process.
Check: find every route that calls a paid API, sends email, or does heavy work. Add a per-user or per-IP limit, cap request sizes, and set a spend limit with your provider as the backstop your code can't defeat. More on hosting an agent.
6. Secrets that ship to the browser
Two flavours, both common.
In source. A key pasted into a config file, committed, and built into the image. ENV values live in image layers and are readable by anyone who can pull it.
In the client bundle. Anything prefixed NEXT_PUBLIC_ or VITE_ is inlined into JavaScript sent to the browser. Vite's docs say it plainly: those variables "should not contain sensitive information such as API keys." A model asked to "make the API key available to the frontend" will do exactly that.
Check: search your repository for sk-, Bearer , api_key, and both public prefixes. Anything you find has been published — rotate it, don't edit it out, and move the call behind a backend.
Ask the model to review its own work — with context
The most efficient step, and it works because the deployment context was missing the first time, not because the model is incapable.
A prompt worth reusing:
This code is about to be deployed on a public URL with no firewall, no rate limiting, and no IP restrictions in front of it. Assume an anonymous attacker can send any request to any route. List concrete ways it could be abused, ordered by severity, and show the specific lines. Don't fix anything yet.
Asking for findings before fixes matters. A model told to "fix the security issues" tends to make sweeping edits; asked to enumerate first, you get a list you can triage — and you'll often find that two items matter and four don't.
A five-minute pass
grep -ri "debug" .— anything true?- List write routes; do they all check auth?
- Search for
allow_origins/Access-Control-Allow-Origin— still*? - Search for
execute(with interpolation. - Which routes cost money if called repeatedly? Are they limited?
- Search for key prefixes and
NEXT_PUBLIC_/VITE_.
FAQ
Is AI-generated code insecure?
Not inherently. It's written for a context with no adversary, so authentication, limits, and production settings are usually absent unless requested.
What's the most common problem?
Debug mode left on. It exposes tracebacks, settings, and file paths, and in Django it also accumulates every SQL query in memory.
Does the platform protect my app?
It terminates TLS and routes by hostname. There's no firewall, rate limiting, or IP filtering, so protections have to be in your app.
Isn't CORS enough to stop others calling my API?
No. CORS is enforced by browsers. Anyone can call your API directly with curl.
How do I stop someone running up my model API bill?
Rate-limit the endpoint in your app and set a spend limit with your provider. Nothing in the hosting layer caps external API spend.
Can I just ask the AI to make it secure?
Ask it to enumerate abuse paths given the real deployment context first, then fix them one at a time. A generic "make it secure" produces sweeping edits you can't review.
Wrapping up
None of this is an argument against building with AI — it's the same review you'd give any code written against a friendlier threat model than the one it's about to meet. The gaps are consistent enough to be a checklist, and the checklist takes five minutes. Run it once before the first deploy, and again the first time you add a route that costs money when someone calls it.