anomalous.
← All posts

Securing vibe-coded apps: the last mile

Christo Goosen9 min read

AI app builders get you a working demo fast. Here is the security checklist for the last mile: data access, secrets, authorisation, LLM features and the agent itself.

Tools like Lovable, Bolt, v0, Replit and Claude Code can take an idea to a working app in an afternoon. The demo works, the sign-up form works, and the data shows up where it should. What they do not reliably give you is the last mile: the gap between “it works” and “it is safe to put real customers and their data in it”. This post is a practical checklist for that gap, and it expands on material from Christo’s VibeSDLC talk, “Turning Roadkill into braAI”.

The short answer: a vibe-coded app is as secure as the checks you put around it. Before real users arrive, make the database enforce who can read what, keep secrets out of the browser, check authorisation on the server for every request, rate-limit anything expensive, and keep production credentials away from the coding agent. The rest of this post is that vibe coding security checklist in detail.

A line from that talk sums up where most organisations are: AI in the enterprise is a lot like high school. Everyone is talking about it, few are actually doing it, most of those do not know what they are doing, and the rest are lying about it. Vibe-coded apps are where that shows up first, because they reach real users before anyone has looked at them with an attacker’s eye.

Why vibe-coded apps fail in predictable ways

Traditional software is deterministic: the same input takes the same path to the same output, so you can review it once and trust the result. A coding model is not. Ask twice and you get two different implementations, and both are optimised for the thing you can see: the happy path working in the preview. Security controls are mostly invisible when they work, so they are exactly what gets skipped, half-done or quietly removed when the model “fixes” an error by loosening a permission.

There is a second thing to secure as well. A coding agent runs in a loop: the model decides what to do, calls tools (your shell, your files, your database, your cloud console), acts, observes the result and goes round again. Whatever those tools can reach, the agent can reach, including when it misreads an instruction or follows one hidden in a file it read. So the last mile has two parts: the app the agent built, and the agent that builds it.

The answer to both is the same idea: put deterministic controls around a non-deterministic generator. You cannot make the model reliably secure, but you can make sure insecure output cannot reach production unnoticed.

1. Lock down data access

Most AI app builders pair a front end with a backend-as-a-service such as Supabase or Firebase, and the front end talks to the database directly. That is a sound design only if the database enforces who can read what. With Supabase, the anon key (or the newer publishable key) in your front end is public by design; the thing that protects your data is row-level security (RLS). If RLS is off on a table, anyone with that key can read the whole table. In 2025 a researcher showed that well over a hundred apps built with Lovable exposed user data in exactly this way (CVE-2025-48757). Lovable disputes the CVE on the grounds that protecting the data is each app owner’s responsibility, which is the point of this post: the platform will not do the last mile for you. Firebase has the same failure mode when security rules are left in test mode.

Enable RLS on every table in an exposed schema, and write policies that tie rows to the signed-in user:

sql
alter table public.documents enable row level security;

create policy "Users read their own documents"
  on public.documents for select
  using ((select auth.uid()) = user_id);

create policy "Users insert their own documents"
  on public.documents for insert
  with check ((select auth.uid()) = user_id);

Then test it the way an attacker would, with only the public key and no session (use your publishable key if you have migrated to the new keys). This should return an empty list, not your data:

bash
curl "https://<project>.supabase.co/rest/v1/documents?select=*" \
  -H "apikey: $SUPABASE_ANON_KEY" \
  -H "Authorization: Bearer $SUPABASE_ANON_KEY"

Do the same for storage: check every bucket is private unless it genuinely holds public files, and that upload paths are scoped to the user.

2. Get secrets out of the browser

Any environment variable with a NEXT_PUBLIC_ or VITE_ prefix that your client code uses is compiled into the JavaScript every visitor downloads. Models add these prefixes to make an error go away. If a database service-role key, a payment secret key or an LLM API key ends up there, it is public. Search your production build, not just your source:

bash
# Run against your built output (.next/static, dist, build)
grep -rEo "sk-[A-Za-z0-9_-]{20,}|sk_live_[A-Za-z0-9]+|sb_secret_[A-Za-z0-9_-]+|eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+" \
  .next/static dist 2>/dev/null

Expect to find your public anon key, which is a JWT. Decode the middle section of every eyJ… token the search returns: a legacy Supabase key whose payload says "role": "service_role" bypasses row-level security entirely, and the words “service_role” will not appear in your bundle as plain text because they are base64-encoded inside the token.

Anything secret belongs in a server-side route or function. If a secret has ever shipped to the browser or been committed to git, rotate it; deleting it from the code does not un-leak it.

3. Check authorisation on the server, not in the UI

Generated apps often hide the admin button from non-admins and call it access control. Hiding a button stops nobody; the API route behind it is still there. For every API route, server action and edge function, check two things on the server: who is signed in, and whether that user is allowed to touch this specific record. Changing /api/invoices/1041 to /api/invoices/1042 in the browser is one of the most common serious bugs in vibe-coded apps (an insecure direct object reference), and it is usually a one-line fix once you look for it. Do not rely on middleware alone either: in 2025 a Next.js vulnerability (CVE-2025-29927) let attackers skip middleware entirely by adding one HTTP header, so any app that only checked authentication there was open. Check again where the data is read.

4. Validate everything that arrives from outside

  • Validate request bodies on the server with a schema (for example Zod), even if the form already validates them in the browser.
  • Verify webhook signatures. A payment webhook that does not check the provider’s signature lets anyone mark an order as paid.
  • Never build SQL, shell commands or file paths from user input with string concatenation.
  • Return generic errors to users and log the detail on the server; stack traces in responses give attackers a map.

5. Rate-limit, and cap your spend

Sign-up, login, password reset and anything that calls an LLM need rate limits. AI features add a new failure mode: denial of wallet. An unauthenticated endpoint that forwards prompts to a model provider can run up a large bill overnight. Put AI endpoints behind authentication, limit requests per user, and set a hard monthly spend limit with your model provider.

6. Treat LLM features as untrusted input and output

If your app itself uses a model (a chatbot, a summariser, an agent that acts for users), prompt injection is in scope. Anything the model reads, including uploaded documents, web pages and emails, can carry instructions. Assume the model’s output is attacker-influenced:

  • Do not render model output as raw HTML; that turns prompt injection into cross-site scripting.
  • Give any tools the model can call the narrowest possible permissions, scoped to the current user, never an admin credential.
  • Require a human confirmation step before the model sends messages, spends money or deletes data.
  • Keep secrets and other users’ data out of the context window.

Our AI security service covers this in depth, including testing for prompt injection.

7. Check every dependency the model added

Models sometimes suggest packages that do not exist, and attackers register those names with malicious code (sometimes called slopsquatting). Before you accept a new dependency, check that it exists, is maintained, and is the package you think it is. Commit your lockfile, run your package manager’s audit, and turn on automated dependency updates.

8. Fix the platform defaults

  • Set security headers: a Content Security Policy, Strict-Transport-Security, X-Content-Type-Options and a frame-ancestors rule.
  • Replace Access-Control-Allow-Origin: * with your real origins on any authenticated API.
  • Turn off debug modes and verbose logging in production.
  • Require email verification, and make sure password reset tokens expire and work once.
  • Turn on backups and test a restore. If an agent or an attacker deletes your data tomorrow, how long until you are back?

Secure the builder, not just the build

In July 2025, Replit’s AI agent deleted the production database behind SaaStr founder Jason Lemkin’s app during a declared code freeze, despite explicit instructions not to make changes. Replit responded by separating development and production databases automatically. The lesson is not that the model was malicious; it is that it had the access to do it. Treat a coding agent like a capable new contractor on their first day:

  • No production credentials on the machine the agent runs on. It works against development and staging; deploys go through CI.
  • Use the tool’s permission system to deny reading secrets and running destructive commands, and review anything it wants to run outside its sandbox.
  • Keep the agent on a branch, with a human approving the merge.

In Claude Code, for example, deny rules in the project’s .claude/settings.json block the agent from reading your environment files and from running commands you never want it to run. Treat these as a seatbelt, not a lock: command rules match on prefixes, so a determined or confused agent can often reach the same result another way. The real controls are the sandbox and the absence of production credentials.

.claude/settings.json
{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Bash(rm -rf:*)",
      "Bash(supabase db reset:*)"
    ]
  }
}

Put deterministic gates in the pipeline

Checklists drift. Gates do not. A short CI workflow that fails the pull request on leaked secrets, known-bad code patterns and vulnerable dependencies catches the regressions a model introduces when it “fixes” something else:

.github/workflows/security.yml
name: security
on: [pull_request]

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Secret scan
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Static analysis
        run: pipx run semgrep scan --config p/default --error
      - name: Dependency audit
        run: npm audit --audit-level=high

(The gitleaks action needs a free GITLEAKS_LICENSE secret on organisation accounts; running the gitleaks CLI directly avoids that.) Add a few tests that assert the security properties you care about, such as one user cannot read another user’s record and an anonymous request to each API route is rejected. Those tests keep holding when the model rewrites the code around them. For anything bigger than a weekend project, a threat model tells you where to focus; we covered how to build one with Claude in threat modelling code and infrastructure as code.

When to bring in a human

Do the checklist yourself first; most of it is an afternoon’s work. Get an independent review before any of these happen: real users sign up, you take payments, or you store personal information. In South Africa that last one brings POPIA into scope; section 19 expects “appropriate, reasonable technical and organisational measures” to protect personal information. A focused web application penetration test against the OWASP ASVS level 1 requirements is a sensible baseline for a first launch.

What we would tell you first

  • Turn on row-level security and test it with the public key before you do anything else.
  • Search your production build for secrets, and rotate anything that has leaked.
  • Check ownership on the server for every route that takes an ID.
  • Rate-limit sign-up, login and every AI endpoint, and set a spend cap.
  • Keep production credentials away from your coding agent.
  • Put deterministic gates in CI so insecure changes fail the build, whoever or whatever wrote them.

If you have built something with AI and want to know whether it is ready for real users, get in touch.

Keep reading

Need a hand with your own project?

Fractional CTO leadership, CISO services and AI security, from Cape Town to anywhere remote.