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 FreeDatabase 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 FreeWhat 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 class | Caught before deploy | Mechanism | Who has to check |
|---|---|---|---|
| Auth route locked behind middlewareOur Pick | Yes, flagged as an error | public_auth_route_protected, AST scan of Hono route registration | Detected for you, you apply the fix |
| Query and schema disagree on a columnOur Pick | Yes, flagged as an error | schema_column_drift, sqliteTable map vs worker source | Detected for you, you apply the fix |
| Unhandled DB error in a route handler | Yes, flagged as a warning | handler_db_call_unguarded, warning severity | You, if you care about the 500 |
| Type suppression at a trust boundary | Flagged and rewritten | ts_ignore_directive plus deterministic conversion to @ts-expect-error | You, for the underlying type error |
| Missing authentication on a route | No | Requires knowing which routes are meant to be private | You |
| Missing authorization (ownership) check | No | Not a syntactic property of the code | You, and this is the important one |
| Secret in client-visible code or bundle | No | Prompt-level guidance only, in guidelines.ts | You |
| Over-permissive CORS origin | No | The correct origin list is not inferable from code | You |
| Unvalidated input reaching a query | No | Requires knowing the intended shape of the input | You |
| Missing rate limit on an expensive route | No | Absence of a feature is not a code defect | You |
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.
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 FreeWhat 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