You describe an app, the tool builds it, you click around, and it works. That is the magic of vibe coding, and also the problem: "it works" is the only test most of these apps ever face. Nobody used the app the way an attacker would, and the model was never asked to.
Here are ten flaws that show up again and again in AI-generated apps, roughly in the order they tend to hurt. None is exotic: each is a well-known web security mistake that makes the demo work faster. Later posts in this series go deeper on several.
Why AI tools produce these
AI coding tools optimise for what you can see: a feature that runs, a test that goes green. Security is about what happens when someone does something you didn't plan for, and the prompt rarely describes that.
- The happy path wins. "Build a page where users edit their profile" produces a page where a logged-in user edits their own profile. Nothing covers the second user.
- Shortcuts are in the training data. Tutorials use hardcoded keys, permissive CORS and open database rules to get you going.
- The UI is visible, the server is not. Reviewing by clicking checks what the UI allows. The API behind it can allow far more.
None of this means the tools are bad. Security just has to be asked for, and then checked.
1. Secret keys in the browser or in the repo
The classic: an API key pasted into a component so the call works, a committed .env file, or a server secret given a public prefix so the frontend can read it. Anything in client code ships in the JavaScript bundle, where anyone can read it. In Next.js, anything prefixed NEXT_PUBLIC_ is inlined into the browser bundle at build time; Vite does the same with VITE_. Some keys are meant to be public (a Supabase anon or publishable key, a Stripe publishable key), but only because the service behind them enforces its own rules. See point 4.
Fix: keep secrets on the server and call the third party from an API route. Add .env* to .gitignore before the first commit. If a secret was ever committed or shipped to a browser, rotate it: deleting it doesn't help, because it stays in git history.
2. Auth enforced only in the UI
The generated app hides the admin button and redirects logged-out users away from /admin. The API route behind it checks nothing. Client-side guards are a usability feature: a script or a curl command can call the endpoint directly, and no UI is involved.
Fix: every handler checks identity and permission itself, on the server, before touching data:
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await requireUser(req); // throws 401 if there is no valid session
await requireRole(user, "admin"); // throws 403
const { id } = await params;
await db.project.delete({ where: { id } });
return Response.json({ ok: true });
}Better still, put the check in a shared wrapper so a new route can't forget it. Our authentication agent checks that identity is really verified on each route.
3. Broken access control and IDOR
Being logged in is not the same as being allowed to see this particular record. The generated handler fetches by id and returns whatever it finds, so an attacker only has to change /api/invoices/41 to /api/invoices/42. Random ids make guessing harder, but ids leak through logs and shared links; they aren't an access check.
Fix: scope every query to the current user or organisation, so a record you don't own simply isn't found:
// Vulnerable: any signed-in user can read any invoice id.
await db.invoice.findUnique({ where: { id } });
// Fixed: someone else's invoice is "not found".
const invoice = await db.invoice.findFirst({ where: { id, ownerId: user.id } });
if (!invoice) return new Response("Not found", { status: 404 });"Fetch by id" is the obvious thing to write, so this flaw is common in generated CRUD apps. The access control agent is built around it.
4. Database rules left open
Backend-as-a-service tools let the browser talk to the database directly. That is fine if the rules saying who can read and write each row are on and correct. Generated setups often leave them off or wide open.
- Supabase: a table the API exposes without row-level security enabled, or with a policy like
using (true), can be read (and often written) by anyone holding the public key, which is in your frontend by design. - Firebase: "test mode" rules let anyone read and write until an expiry date. They are meant to be temporary, but are routinely extended or swapped for something equally open.
-- Fix: turn RLS on, then allow each user only their own rows.
alter table notes enable row level security;
create policy "owners manage own notes" on notes
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);Check the real project, not just the code: the app working tells you nothing about these rules. See the docs for Supabase row-level security and Firebase security rules.
5. Trusting client-supplied fields
Generated code loves update(req.body). It saves lines, and lets the client set any field it likes.
// Vulnerable: the client decides its own role and the order total.
await db.user.update({ where: { id: user.id }, data: req.body });
await db.order.create({ data: { ...req.body, userId: user.id } });A request that includes "role": "admin" or "total": 1 is accepted as if it came from your form. This is mass assignment.
Fix: accept an explicit allow-list of validated fields:
const input = z.object({ name: z.string().max(80), bio: z.string().max(500) }).parse(req.body);
await db.user.update({ where: { id: user.id }, data: input });For orders, accept a product id and quantity, and look the price up in your own catalogue. The same goes for userId, isAdmin, status and anything else the client has no business choosing.
6. String-built SQL
Asked for a search feature, a model will often write a template string, the shortest thing that runs. Input containing SQL syntax then changes what the query does.
// Vulnerable: input becomes part of the query itself.
await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// Fixed: the value is a parameter and can never be read as SQL.
await db.query("SELECT * FROM users WHERE email = $1", [email]);ORMs are safe by default, but their raw-query escape hatches (Prisma's $queryRawUnsafe, or sequelize.query with interpolated strings) are not. The same goes for NoSQL filters built straight from request JSON. See the SQL injection agent.
7. Rendering untrusted or model-generated HTML
User Markdown, API content or an LLM reply gets rendered with dangerouslySetInnerHTML (React) or v-html (Vue) so it "looks right". If that text is attacker-influenced, it can carry script that runs in other users' sessions. Model output counts as untrusted: a prompt injection in a document the model read can make it emit markup you didn't intend.
Fix: render as text by default. For rich formatting, use a Markdown renderer that doesn't allow raw HTML, or sanitise with a maintained library such as DOMPurify. See the cross-site scripting agent.
8. No rate limiting
Login, password reset, OTP and AI endpoints are generated as plain handlers that do the job as often as asked. Without limits, an attacker can guess passwords and six-digit codes, flood your SMS provider, or run up your model bill.
Fix: limit by IP and by account on auth and code-verification routes, and by user (with a spend cap) on anything that costs money per call. Use your platform's limiter or a maintained library, and return 429. Make OTPs expire and lock after a few wrong attempts. A later post in this series covers this in depth.
9. Verbose errors, debug mode and open CORS
Development defaults survive into production: stack traces in responses, debug left on, and a CORS policy that reflects any origin.
// Vulnerable: leaks internals, and lets any site make credentialed calls.
app.use(cors({ origin: true, credentials: true })); // reflects any Origin
app.use((err, req, res, next) => res.status(500).json({ error: err.stack }));Fix: return a generic message and an error id, and log the detail server-side. Turn debug off in production. Allow only the specific origins that need access. The secrets and data exposure agent covers data leaking through logs and error responses.
10. Hallucinated or unpinned dependencies
Models sometimes suggest a package that doesn't exist. If someone registers that name and you install it, you run their code. Generated projects also use latest or loose ranges and skip the lockfile, so what you tested isn't what you ship.
Fix: check that every new package is the real, established one (publisher, age, repository link) before you install it. Commit the lockfile and use npm ci in CI. The supply chain agent reviews pinning, lockfiles and registries.
Prompting for safer code
Rules files (such as CLAUDE.md, AGENTS.md or Cursor rules) change what these tools produce. Concrete instructions beat "be secure":
- "Every API route and server action must authenticate the caller and authorize them for the specific record, on the server."
- "Scope every database query to the current user or organisation. Never fetch a record by id alone."
- "Never use
req.bodydirectly as data. Validate with a schema and accept only named fields. Compute prices, roles and ownership on the server." - "Never put secrets in client code or in public-prefixed variables. Use parameterised queries only, and never render untrusted or model-generated text as HTML."
- "Add rate limits to auth, OTP and AI routes. Don't return stack traces. Don't add a dependency without telling me, and don't invent package names."
Then verify: have the model review its diff against that list, and have a person check anything touching auth, payments or data access.
Pre-launch checklist
- No secrets in the repo, the git history or the browser bundle; anything that ever leaked is rotated
- Every route checks who you are and whether you may touch this record, on the server
- Changing an id in a URL returns 404, not someone else's data
- Row-level security is on (Supabase) or real rules are deployed (Firebase), checked in the project, not just the code
- Write endpoints use an allow-list of fields; prices, roles and owners are set server-side
- No string-built SQL; raw-query helpers are parameterised
- No
dangerouslySetInnerHTMLorv-htmlon untrusted or model-generated text - Login, signup, OTP and AI endpoints are rate limited, and AI endpoints have a spend cap
- Production returns generic errors, debug is off, and CORS lists specific origins
- Every dependency is real and pinned, and the lockfile is committed
How OpenRouting helps
OpenRouting reviews your code, not your Supabase or Firebase dashboard and not your running app. It runs specialist review agents on your repository, including access control, SQL injection, cross-site scripting and secrets and data exposure. A full-repo scan also looks for secrets committed anywhere in your git history. Every finding gets a verdict (real or likely false positive), a plain-English explanation and a suggested fix. It can't enforce rate limits or confirm that your database rules are switched on in a live project.
Pull-request review scans the files a PR changes and posts a single summary comment, so these flaws can be caught in AI-written changes before they merge. If your repo contains an AI-agent setup, the agent setup review checks that too.