Engineering

The security holes AI puts in your backend by default

What generated code gets wrong, and which parts a machine can catch

AI-generated backends are usually functional and reliably under-authorized. This is a specific account of the failure classes we see in generated Cloudflare Worker and D1 code, which ones our pre-deploy gate catches by name, and the much harder set that no static analyzer can see. It ends with a review checklist you can run without being a security engineer.

Author
By Fabricate Team
Last updated
Updated July 26, 2026
Reading time
21 min read
Key takeaways
  • AI builders optimize for a working demo. A demo with one user never exercises authorization, so authorization is the thing generated backends most reliably omit.
  • Authentication and authorization are different problems. "Is this a logged-in user" is easy to generate. "Does this user own this row" is the check that goes missing.
  • Some failures are structural and a machine can find them: auth middleware swallowing the login route, schema and query disagreeing about a column, a type suppression left in the source. Judging whether that suppression sits somewhere dangerous is still yours.
  • Fabricate names the structural ones before deploy in worker/agents/validators/preDeploySafetyGate.ts and rewrites a subset automatically. It mostly does not stop the deploy over them, so a named finding is still yours to act on. None of its rules check ownership, because ownership is not a syntactic property.
  • Secrets, CORS, input validation and rate limiting are all things the model will happily leave in a permissive default state if your prompt never mentioned them.
  • A short manual pass over the seven checks at the end of this post covers the failure classes that show up before you have real users. Do it before launch, not after.
Table of Contents

Is AI-generated code secure?

AI-generated backend code is usually functional and reliably under-authorized. It authenticates fine, it validates a little, and it almost never checks whether the logged-in user is allowed to touch the specific record they just asked for. That gap is the one that turns a working app into an open one, and it is the hardest of the common failures to notice from the inside, because everything looks correct until a second account exists.

The reason is structural rather than mysterious. Every AI builder, ours included, optimizes for the moment the preview loads and the thing works. You are the only user in that moment. A single-user demo never distinguishes "fetch order 41" from "fetch order 41 if it belongs to me", because both return the same row. The model has no signal that one of them is wrong, and neither do you until the second user signs up.

Two categories are worth separating, because they need different responses. Structural failures are arranged in a way a parser can recognise as wrong regardless of what the app is meant to do. Auth middleware mounted over the login route is structural. A query naming a column the schema does not declare is structural. These can be caught mechanically, and Fabricate names a set of them by rule before anything deploys.

Semantic failures cannot. Whether a route should require ownership, whether this CORS origin is the one you meant, whether this endpoint deserves a rate limit, all depend on intent that exists nowhere in the code. A human has to look. The rest of this post tells the two apart so you spend review time on the half that needs you.

Note

Nothing here is a generic OWASP restatement. Every failure class below is one we gate on by name, or one we specifically do not gate on and will tell you why.

Why generated Worker and D1 code fails in specific places

Generated backends fail at their seams, and the seams are set by the stack. A generated Fabricate app is a React and TypeScript frontend built with Vite and styled with Tailwind, served alongside a Hono app on Cloudflare Workers, with D1 (SQLite) through Drizzle when the app needs a database, plus R2 for files and KV for key-value storage. Payments arrive through a Stripe-enabled template when the app takes money.

That shape puts the route registration, the middleware wiring, the handler and the database query within a few lines of each other. When a model writes all four in one pass, mistakes do not scatter randomly. They cluster at the joins: middleware applied at the wrong scope, a query that drifted from a schema declared in another file, a binding referenced before it was provisioned.

Fabricate generates through a phasic pipeline (worker/agents/execution/phasicStateMachine.ts): plan a deployable milestone, implement it, deploy, validate, repeat. The per-file code fixers are switched off. In worker/agents/inferutils/config.ts both realtimeCodeFixer and fastCodeFixer resolve to a DISABLED model, because a slow LLM re-reading every generated file cost several minutes per generation, and the code comment recording that decision puts the saving at three to eight minutes. That puts more weight on the static gate and on the quality of the first pass, so the gate has to be real rather than decorative.

The gate lives in worker/agents/validators/preDeploySafetyGate.ts, just under 3,900 lines of static checks that run over generated files before deployment. The structural rules parse the file with Babel and walk the AST; a few narrower ones are pattern matches over the source text. Each check emits a named rule with a severity of error or warning, and the gate marks a build safe only when the error count is zero.

What happens to an unsafe build is worth stating plainly, because calling the gate blocking would badly overstate it. Error-severity findings are deterministically rewritten where a mechanical fix exists, and otherwise recorded in the build log while the deploy continues, on the reasoning that a flagged preview is more useful to you than no preview at all. Two rules, syntax_parse_failure and missing_import_target, can refuse a deploy outright, but only on the tool-first path used for conversational edits. A phasic build runs the gate inside its phase loop and then calls the deploy with the deploy-time gate skipped, and the code comment there is explicit about why: throwing would crash the phase and leave you looking at the template.

So the gate reliably names these failures. On a normal build it does not refuse to ship them. A named finding is a review item, not a problem already solved on your behalf.

The login route swallowed by its own auth middleware

The most instructive auth failure in generated code is not an unprotected route. It is over-protection: the model mounts authentication middleware broadly enough that it covers sign-in and registration too, so nobody can obtain a session in the first place. The app is not breached, it is bricked, and the symptom looks like a broken login form rather than a middleware scope problem.

It happens because "protect the API" is a reasonable instruction to follow literally. A line like app.use("/api/*", authMiddleware) reads as good hygiene, and is, for every route except the handful that must stay reachable to a stranger.

The public_auth_route_protected rule detects exactly this. It inspects worker/userRoutes.ts and files under worker/routes/, parses the Hono route registrations, and holds a fixed set of paths that must remain reachable without a session: /api/auth/register, /api/auth/login, /api/auth/signup, /api/auth/sign-up, /api/auth/sign-in, /api/auth/forgot-password and /api/auth/reset-password.

It flags two shapes. Inline: auth middleware passed as an argument between the route path and the final handler on one of those paths. Domination: a broad app.use mount whose glob is one of *, /*, /api/* or /api/auth/*, covering the public routes wherever they were declared. Middleware is identified conservatively, by a case-insensitive match on the literal string "auth" in the identifier or callee name, so logger or cors does not trip the rule. It is error severity in both cases. The broad-mount message carries the fix: scope the middleware to a protected sub-tree such as /api/protected/* and keep register, login and signup outside it. The inline message tells you to drop the middleware from that route.

The same guidance is applied at generation time. worker/agents/prompts/guidelines.ts instructs the model to apply auth middleware per protected route and never as a global mount over *, /api/* or /api/auth/*. The gate exists because a prompt instruction is a strong prior, not a guarantee.

Important

If your generated app has a login form that always fails and an API that always returns 401, check middleware scope before you check credentials. Locking out every user is the most common way over-eager auth wiring manifests.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Database calls in handlers with nothing wrapped around them

A route handler that awaits a database query with no error handling around it is a reliability problem that becomes a security problem through the back door. The handler_db_call_unguarded rule finds these, and the name invites a wrong reading, so it is worth being precise.

What it detects: inside files under worker/, it parses the AST and walks for await expressions whose argument is a database call. If that await sits inside a function passed to a route registration (get, post, put, delete, patch, options, head, all, use or on) and there is no try statement anywhere in its ancestor chain, the rule fires. It is warning severity, so it informs rather than blocks.

What it does not check is permission. The rule makes no assertion about whether the handler verified a session or an owner. It is an error-handling rule, and it would be easy and wrong to present it as an access-control check.

The security relevance is indirect but real. The rule message names the mechanism: an unhandled database error becomes an opaque 500, because Hono default error handling hides the cause. An endpoint returning an undifferentiated 500 for malformed input, a missing row, a permission failure and a genuine outage is an endpoint you cannot reason about. You lose the ability to distinguish "this record does not exist" from "this record is not yours", and you lose the log line that would have shown someone probing. Wrapping the call and returning a structured response is also what makes the endpoint auditable later.

Suppression comments sitting on a trust boundary

A @ts-ignore comment is a security finding when it sits where untrusted data enters your system. The directive suppresses every error on the following line, so a genuine type mismatch on a request body, a parsed JSON payload or a database row becomes an assumption the compiler is no longer allowed to question. Types are not a security mechanism, but at a trust boundary they are the cheapest one available, and a suppression comment is exactly how that check gets removed.

The ts_ignore_directive rule scans TypeScript and JavaScript files for the directive and reports each occurrence at warning severity, with the message that it suppresses all errors on the next line and should be fixed or replaced with @ts-expect-error.

Fabricate does not only report this one. The same file exports a deterministic fix pass whose transforms include rewriting the // @ts-ignore line comment into "// @ts-expect-error -- auto-converted from @ts-ignore". The fix and the detector match on the same pattern, so everything the rule reports is what the pass converts. The two directives look similar and behave very differently. @ts-ignore stays quiet forever, including after someone fixes the underlying problem, so it accumulates as debt nobody can find. @ts-expect-error errors once the suppressed issue is resolved, forcing the comment to be deleted the moment it stops being true. That conversion does not fix the type error, and we are not claiming it does. It turns a permanent silent suppression into one that expires.

When reviewing generated code, treat any remaining suppression near request parsing, session handling or a payment webhook as a must-read line. Elsewhere it is usually cosmetic.

When the query and the schema disagree

Data-layer bugs in generated apps are usually disagreements between two files written minutes apart. The schema says one thing, the query assumes another, and nothing complains until the row is requested in production. Three rules cover this, all at error severity.

schema_column_drift is the broadest. It collects every sqliteTable declaration to build a map of tables to their real columns, walks the worker source for references to those tables, and flags any column access the schema does not declare. The message names the table, the missing column and the file where the table is defined, and states the consequence: D1 will throw "no such column" at runtime. It is a correctness rule with a security edge, because the same drift produces a handler that appears to filter on a userId column that does not exist.

sql_insert_missing_values_keyword covers a narrow deterministic SQLite failure in .sql files: an INSERT INTO with a column list followed immediately by a value tuple, with no VALUES keyword between them. Wrangler reports it as near "(": syntax error, which is unhelpful to receive mid-deploy. The gate catches it up front rather than sending it around a retry loop, and the deterministic fix pass can insert the keyword.

unresolved_resource_placeholder inspects the wrangler config for placeholder tokens never replaced with a real resource id. Without a real id the binding resolves to undefined and the Worker returns a 500 on first use. Not a subtle failure, but one that surfaces after launch rather than before if nothing checks the config.

None of these three are access control. They are the difference between an app that works and an app that looks like it works, which is the class most likely to survive your own testing and reach a user.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

What a static gate cannot catch

A static analyzer can only find failures visible in the shape of the code. Everything that depends on intent is out of reach, and the security failures that depend on intent are the expensive ones. This is not a limitation we expect to engineer away, because the information is not in the file.

The boundary is concrete. A parser can tell that app.use("/api/*", authMiddleware) covers /api/auth/login, because both facts are literally present in the source. It cannot tell whether a handler for /api/invoices/:id should require the invoice to belong to the caller, because that depends on whether this is an invoicing app or a public status board. Both are valid programs. Only one is the app you meant to build.

Two rows in the table below deserve emphasis, because generated code has a specific bias. On CORS, a permissive origin is what makes the preview work from every context you might test it in, so it is the configuration that gets written and the one that survives to production unless you say otherwise. On rate limiting, the model adds it when asked and rarely when not, because an unrated endpoint is indistinguishable from a rated one in every test where you are the only caller.

Failure classCaught before deployMechanismWho has to check
Auth route locked behind middlewareOur PickYes, flagged as an errorpublic_auth_route_protected, AST scan of Hono route registrationDetected for you, you apply the fix
Query and schema disagree on a columnOur PickYes, flagged as an errorschema_column_drift, sqliteTable map vs worker sourceDetected for you, you apply the fix
Unhandled DB error in a route handlerYes, flagged as a warninghandler_db_call_unguarded, warning severityYou, if you care about the 500
Type suppression at a trust boundaryFlagged and rewrittents_ignore_directive plus deterministic conversion to @ts-expect-errorYou, for the underlying type error
Missing authentication on a routeNoRequires knowing which routes are meant to be privateYou
Missing authorization (ownership) checkNoNot a syntactic property of the codeYou, and this is the important one
Secret in client-visible code or bundleNoPrompt-level guidance only, in guidelines.tsYou
Over-permissive CORS originNoThe correct origin list is not inferable from codeYou
Unvalidated input reaching a queryNoRequires knowing the intended shape of the inputYou
Missing rate limit on an expensive routeNoAbsence of a feature is not a code defectYou

Rule names in the "Mechanism" column are from the rule union in worker/agents/validators/preDeploySafetyGate.ts. Error severity means the gate names the issue and marks the build unsafe, not that the deploy stops: only syntax_parse_failure and missing_import_target can refuse a deploy, and only on the tool-first path, not in a phasic build. No rule in that union evaluates ownership.

Authorization is the one that gets you

Authentication asks whether the caller is a known user. Authorization asks whether this known user may touch this specific object. Generated backends get the first right and skip the second, and the skip is invisible until you have two accounts.

The shape is a handler that reads an identifier out of the URL and queries by it alone. The route sits behind auth middleware, so it feels protected. A logged-in user changes the number in the path and reads somebody else record. No exploit to write, no tooling required. The reason this class shows up so consistently in AI-generated apps is more specific than the general prevalence of broken access control: the demo that convinced you the app worked had one account in it.

The correction is small and mechanical. Every query that reads or mutates a user-owned row needs the owner in its predicate, not just the object identifier. Deletes and updates matter more than reads, because a read leaks and a write destroys. It is the kind of instruction a model follows well once it is stated outright, which is why one explicit follow-up prompt beats reviewing every route by hand.

Confirm two preconditions first. Every table holding user data needs an owner column, and every protected handler needs the caller identity from the session rather than the request body. If the owner arrives in the request body, the caller controls it and the check proves nothing.

Tip

Test it with two accounts. Create a record as user A, copy its id, sign in as user B, and request it. A 404 is correct. Anything that returns the record is a live authorization hole.

The authorization pass to run after your app is generated
Audit every route in worker/userRoutes.ts for object-level authorization. For each handler that reads, updates or deletes a record belonging to a user: confirm the table has an owner column, confirm the handler resolves the caller identity from the authenticated session and never from the request body or a query parameter, and add the owner to the WHERE clause of the query itself rather than checking it after the row is fetched. Return 404 rather than 403 when the row exists but belongs to someone else, so the endpoint does not confirm which ids are real. List every route you changed and every route you deliberately left public, with the reason.
Two details do real work here. Putting the owner in the WHERE clause rather than in a post-fetch check means the wrong row is never loaded, so it cannot leak through a log line or an error message. Asking for the list of routes left public forces the model to state its assumptions, which is the part you actually review.

A pre-launch review a non-expert can run

A short ordered pass, scoped to what someone without a security background can verify by looking and clicking. It is not a substitute for a professional review of an app handling money, health data or anything regulated.

1. Two-account authorization test. Create a record as one user, sign in as a second, request that record by id, then try to update and delete it. All three should fail. Repeat for each type of record the app stores. Nothing else on this list is as likely to surface a real hole.

2. Search the frontend for secrets. Look through src/ for anything resembling a key, token or password. Any secret reachable from the browser is public, including one in a build-time environment variable inlined into the bundle. worker/agents/prompts/guidelines.ts tells the model that frontend code cannot read environment variables and must proxy through a Worker route, but a prompt asking for a quick integration can still produce a hardcoded key. Confirm by searching the JavaScript your deployed site actually loads.

3. Read your CORS configuration. Find the CORS setup in the Worker and check the allowed origin. A wildcard is fine for a genuinely public read-only API and wrong for anything carrying a session. Replace it with your actual domain.

4. Check what your write endpoints accept. For each route that creates or updates a record, ask whether the handler validates the shape and range of what arrives or simply trusts it. Fields the user must never set, such as a role, a price, an owner id or an is-admin flag, must be ignored if they appear in the body. Mass assignment of a whole request body into a database insert is the pattern to look for.

5. Rate limit the routes that cost money or send mail. Anything triggering an email, an SMS, a file upload, a payment attempt or an LLM call needs a limit. A plain database read usually does not, at this stage.

6. Confirm what your errors say. Trigger a failure and read the response. It should be a structured message, not a stack trace and not a raw database error, which leaks table and column names.

7. Re-run the two-account test after your next generation. Follow-up prompts modify handlers, and an authorization check that existed last week can be removed by an unrelated feature request without anything in the build telling you.

Important

Step 7 is the one people skip. Access-control checks are removed by accident far more often than they are removed on purpose, because they are a few characters inside a query that a rewrite of that query does not preserve.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

What Fabricate does not do for you

Fabricate names a defined set of structural failures before deploy and rewrites a few automatically. On a normal build it does not stop the deploy over them, it records them. It does not audit your authorization model, it does not know which routes should be public, it does not review your CORS origins, and it does not add rate limits you did not ask for. Any AI builder claiming otherwise is describing a product that would have to understand what your app is for.

The competitive picture deserves accuracy too, because the temptation is to imply everyone else is worse. These failure classes are not Fabricate-specific. They are properties of language models writing backend code, so you should expect to meet them in the output of any tool that generates a backend from a prompt, and in code you write yourself at two in the morning. What differs between tools is how much of the structural half gets checked mechanically before shipping, and whether you are told honestly about the semantic half that cannot be checked at all.

Model routing is worth stating plainly, since it affects code quality. Fabricate routes automatically per step: Gemini 3.1 Pro Preview plans the blueprint, Gemini 3.6 Flash implements phases and generates code, Gemini 3.5 Flash-Lite handles template selection and context work, and Grok 4.3 handles conversation, project setup, file regeneration and the deep debugger. There is no model picker.

One practical note, because a review pass costs credits like anything else. Free is 60 credits a month with a daily cap and one published project. Pro is $25 a month for 350 credits, Scale is $50 a month for 700, with annual billing on both paid tiers. Per-generation caps apply on every tier; current figures are on the pricing page. Credits are weighted tokens rather than messages, and they do not roll over. The authorization pass above is a single follow-up prompt rather than a rebuild.

None of that changes the practical takeaway. Run the two-account test. It needs two accounts and no expertise, and it finds the class of hole a static gate structurally cannot.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Frequently Asked Questions

Is AI-generated code secure enough to launch with?
For a small app with real users, it is close but not sufficient on its own. Generated code typically gets authentication right and object-level authorization wrong, so the gap between "it works" and "it is safe" is usually one review pass rather than a rewrite. Run the two-account test described in this post before you launch: create a record as one user, then try to read, update and delete it as another. If any of those succeed, you have a live hole, and it is usually fixed with one follow-up prompt.
What is the difference between missing authentication and missing authorization?
Authentication answers "is this a known user". Authorization answers "may this known user touch this specific object". A route behind auth middleware is authenticated, and it can still let any logged-in user read any other user record by changing an id in the URL. AI builders get authentication right because it is a visible feature you asked for. They omit authorization because a single-user demo returns identical results whether the ownership check is present or not.
Can a static analyzer detect a missing ownership check?
No, and not for want of effort. Whether a route should require ownership depends on what the app is for, and that intent is not present in the code. A handler for /api/invoices/:id with no ownership predicate is correct for a public status board and a serious hole for an invoicing app. Both compile to the same shape. Fabricate pre-deploy gate in worker/agents/validators/preDeploySafetyGate.ts flags structural failures at error severity, such as auth middleware covering the login route and queries referencing columns the schema does not declare, but no rule in it evaluates ownership.
Why would AI protect my login route, and why is that a problem?
Because "protect the API" is a reasonable instruction to follow literally, and a single broad mount such as app.use("/api/*", authMiddleware) reads as good practice. It also covers sign-in and registration, so no new user can ever obtain a session and no existing user can sign back in. Fabricate public_auth_route_protected rule flags this at error severity by parsing Hono route registration and checking a fixed set of paths, including /api/auth/login, /api/auth/register and /api/auth/reset-password, against both inline middleware and broad mounts over *, /*, /api/* and /api/auth/*. The generation prompt in worker/agents/prompts/guidelines.ts also tells the model never to mount auth middleware on those globs.
Where do secrets leak in AI-generated apps?
Into anything the browser can download. The two common paths are a key hardcoded directly in frontend source and a key placed in a build-time environment variable that gets inlined into the JavaScript bundle. Both are fully public once deployed. Fabricate generation guidance instructs the model that frontend code cannot read environment variables and must proxy through a Worker route, with secrets set in the project Environment settings, but that is a strong prior rather than an enforced rule. Verify by opening your deployed site and searching the loaded JavaScript for the key.
Does @ts-ignore in generated code actually matter?
It matters where untrusted data enters the system, and rarely anywhere else. The directive suppresses every error on the following line, so a real type mismatch on a request body or a database row becomes an unchallenged assumption. Fabricate flags it at warning severity and its deterministic fix pass rewrites each occurrence to @ts-expect-error, which errors once the underlying issue is fixed and therefore cannot linger silently. That conversion does not fix the type error itself. Read any remaining suppression near request parsing, session handling or a payment webhook.
How often should I re-run a security review on a generated app?
After any generation that touched a route handler or a database query. Ownership checks are a few characters inside a query predicate, and a follow-up prompt that rewrites that query for an unrelated reason can drop them without any build step noticing. Re-running the two-account test is the cheapest regression check available for the failure class that matters most.
Do I still need a professional security review?
If your app handles payments beyond a standard Stripe checkout, stores health or financial records, or operates under a compliance regime, yes. The checklist in this post is scoped to what a non-expert can verify by looking and clicking, and it is meant to catch the failure classes that appear in almost every generated backend. It is not an assessment of your threat model, and it does not cover dependency supply chain, session fixation, or anything requiring an adversarial mindset applied to your specific domain.