11 Signs Your AI-Built App Needs a Security Audit
Built your app with a coding agent? 11 warning signs it needs a security audit, each checkable in five minutes, and how to run the full audit yourself.
11 Signs Your AI-Built App Needs a Security Audit
If a coding agent wrote most of your app and nobody has deliberately tried to break it, it needs a security audit. The signs below are the gaps agents leave most often: secrets shipped to the browser, API routes that never check who is calling, IDs you can swap in the URL, unlimited login attempts. Each one takes about five minutes to check yourself.
This isn't because AI-written code is uniquely bad. Agents optimize for the request in front of them ("load the user's projects on the dashboard"), and the happy path works. Whether a different user can load those projects is a question nobody asked. The code looks competent, so it passes review, and the gap reaches production.
Run these checks against staging or your own test accounts. Several of them send deliberately bad requests.
1. Secrets end up in the browser
Any environment variable prefixed NEXT_PUBLIC_ (Next.js) or VITE_ (Vite) is compiled into JavaScript anyone can download. Agents add the prefix when something "doesn't work on the client".
Check: list the prefixed names (not values), then search the built bundle for real secret formats.
grep -hE "^(NEXT_PUBLIC_|VITE_)" .env* | cut -d= -f1
npm run build
grep -rEo "sk_live_[0-9A-Za-z]{6}|sk-proj-[0-9A-Za-z_-]{6}|sk-ant-[0-9A-Za-z_-]{6}|AKIA[0-9A-Z]{16}|sb_secret_" .next/static dist 2>/dev/null | head
A prefixed name containing SECRET, SERVICE_ROLE or PRIVATE, or any match in the bundle, is a leak. Move that call to the server and rotate the key.
2. API routes answer without a session
The agent protected the page and forgot the API route behind it. Admin routes hidden from the menu but unprotected on the server are the same bug.
Check: list route handlers that never mention your auth helper, then call a few with no cookie.
# Next.js App Router; adjust the pattern to your auth library
grep -rL --include='route.ts' --include='route.js' -E "auth\(|getServerSession|getUser|currentUser" app/api
curl -s -o /dev/null -w '%{http_code}\n' https://staging.yourapp.com/api/projects
Every non-public route should return 401. Then log in as a normal user and call an admin endpoint directly; expect 403. Don't rely on middleware alone: CVE-2025-29927 let requests skip Next.js middleware entirely on unpatched self-hosted apps. Check the session in the handler or data layer too.
3. Changing an ID shows someone else's data
This is IDOR, or broken object-level authorization, the first entry in the OWASP API Security Top 10. The route checks that you're logged in, then loads /api/invoices/:id without checking that the invoice is yours.
Check: create two accounts. Copy user B's session cookie from DevTools and request a resource that belongs to user A.
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Cookie: <user B's session cookie>" \
https://staging.yourapp.com/api/invoices/<user A's invoice id>
Expect 403 or 404. A 200 is a data breach waiting for someone curious. The fix is a query scoped to the caller, such as where: { id, ownerId: session.user.id }, not a check in the UI.
4. Your database trusts the browser
Supabase and Firebase let the browser query the database directly, which is only safe when row-level rules do the authorization. Agent-built apps often ship with Row Level Security off, a policy that allows everything, Firebase rules still in test mode, or the Supabase service-role key (which bypasses RLS) used in client code.
Check (Supabase SQL editor):
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
select tablename, policyname, cmd, roles, qual
from pg_policies
where schemaname = 'public';
With Supabase's default grants, a table showing rowsecurity = false can be read, and usually written, by anyone holding your public anon key. A policy whose qual is just true is open too. In Firebase, look for allow read, write: if true; or a request.time < timestamp.date(...) test-mode rule in firestore.rules.
5. Login, signup and OTP have no rate limit
Without limits, your login form is a password-guessing API, your signup form is a bot farm, and your SMS code endpoint is a bill: SMS pumping fraud triggers codes to number ranges where the attacker earns a share of the fees.
Check: send 30 wrong passwords for a test account.
for i in $(seq 1 30); do
curl -s -o /dev/null -w '%{http_code} ' -X POST https://staging.yourapp.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"test@yourdomain.com","password":"wrong"}'
done; echo
You should see 429s well before attempt 30. Adapt the URL and body to your auth library, and repeat for signup, password reset and OTP.
6. CORS reflects any origin
"Fix the CORS error" tends to produce origin: true or Access-Control-Allow-Origin: *. Reflecting any origin together with Access-Control-Allow-Credentials: true lets a page on another site read your API's responses as the logged-in user whenever the browser sends the session cookie, which it always does for SameSite=None cookies.
Check:
curl -s -D - -o /dev/null -H "Origin: https://evil.example" \
https://staging.yourapp.com/api/me | grep -i '^access-control'
If https://evil.example comes back alongside access-control-allow-credentials: true, replace it with an explicit allowlist of your own origins. A bare * is only acceptable for public, unauthenticated data.
7. Queries are built from strings
Template literals inside SQL calls, and request bodies passed straight into MongoDB filters, are the shortcuts that become injection.
Check:
grep -rnE '(query|execute|queryRawUnsafe|executeRawUnsafe)\(\s*`[^`]*\$\{' \
--include='*.ts' --include='*.js' --exclude-dir=node_modules --exclude-dir=.next .
grep -rnE 'find(One)?\(\s*req\.(body|query)|\$where' \
--include='*.ts' --include='*.js' --exclude-dir=node_modules --exclude-dir=.next .
Each hit needs placeholders ($1 with a values array, or Prisma's tagged $queryRaw) or explicit field picking. Passing req.body into a Mongo filter allows operator injection such as {"password": {"$ne": null}}.
8. Webhooks accept unsigned requests
A Stripe, GitHub or Clerk webhook endpoint is a public URL. If it doesn't verify the signature, anyone can POST "payment succeeded" to it.
Check: the handler should verify the raw body before doing anything else.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body; parsing it first breaks the signature
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
req.headers.get('stripe-signature') ?? '',
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// handle event.type here
return new Response('ok');
}
A POST to the staging endpoint without a signature header should get a 400.
9. File uploads trust the client
Agents accept whatever the browser sends: any size, any type, stored in a public bucket, served from your own domain. An uploaded HTML or SVG file served from your origin is stored cross-site scripting.
Check: upload an SVG containing <script>alert(document.domain)</script> as an avatar, then open the file's URL directly. If the alert fires on your domain, serve user files from a separate domain or with Content-Disposition: attachment, and validate type by file content rather than extension or the client's Content-Type. Confirm there's a size limit and that private files need a signed URL.
10. Nobody reviewed the dependencies
Every package the agent installed is code you now ship. Agents also suggest packages that don't exist or are near-misses of popular names, and attackers register those names ("slopsquatting").
Check:
npm audit --omit=dev
npm outdated
npm ls --depth=0
Look up any top-level package you don't recognize on npmjs.com: maintainer, weekly downloads, linked repository. Commit your lockfile.
11. You can't answer "who did that?"
If a customer's data changed yesterday, can you say which account changed it, when, and from which IP? Many agent-built apps log nothing beyond console.error. And if every request in your logs shows the same address, you're recording your load balancer, not your users.
Check: pick a sensitive action (role change, deletion, export) and reconstruct the most recent one from your logs. If that takes more than five minutes, you can't investigate an attack either.
How to run the full audit
The checks above catch the obvious cases. A real audit goes route by route, which is work a coding agent does well when you give it a structured brief instead of "is this secure?"
- Copy a threat-modeling prompt. /threat-modeling has free copy-paste prompts per category. For the signs above: authorization (2, 3, 4), secrets (1), authentication and rate limits (5), XSS / CSRF / CORS (6), injection (7), webhooks (8), file uploads (9), supply chain (10) and storage & logs (11).
- Paste it into your agent at the project root. It inventories your code, audits the real files and writes a report under
threat/<category>/, with each finding pinned to a file and line. It runs locally, so your code isn't uploaded anywhere; see the AI security audit that never uploads your code. - Fix in code, then re-run the same prompt to confirm each finding is gone. Start with authorization and secrets, the two categories where one gap exposes every user.
- Watch production for the attacks those gaps invite. Fixing a missing check doesn't tell you whether someone already found it, or is now trying the next route. Each report also lists the runtime detections for its category.
The full walkthrough is in how to audit your app's security with AI, and how to secure AI-generated code covers why agent-written code fails the way it does.
The checklist
- No secrets in
NEXT_PUBLIC_/VITE_variables or the built bundle - Every non-public API route returns 401 without a session
- Admin routes return 403 for normal users, enforced on the server
- User B gets 403 or 404 on user A's resource IDs
- RLS enabled on every Supabase table; no open Firebase rules
- Login, signup, reset and OTP return 429 under repeated attempts
- CORS uses an explicit allowlist of origins
- No string-built SQL; no request bodies passed into database filters
- Webhooks verify signatures on the raw body
- Uploads have a size limit, a content check, and aren't served as HTML from your domain
-
npm auditreviewed; every top-level dependency recognized - Logs record user, route, status and the real client IP
Find it in code, watch it in production
An audit is a snapshot. The next feature your agent ships can reopen a gap, and attackers probe the ones you haven't found yet. SecureNow covers the production half: one npm package records each request as a trace with the real client IP, route and status, and its MCP server lets your coding agent see live attacks against the code it just audited, then propose a block or rate limit for you to approve. Start with the free prompts; if you want production visibility too, there's a 14-day SecureNow trial with no credit card.
Frequently Asked Questions
Is my vibe-coded app secure?
Probably not entirely, unless someone has deliberately tried to break it. Apps built quickly with coding agents commonly ship with missing authorization checks, secrets in client bundles and no rate limits. The checks in this post take about five minutes each and show where you stand.
What are the biggest security risks in AI-generated code?
Missing authorization (users reach each other's data by changing an ID), secrets exposed to the browser, open Supabase or Firebase rules, unlimited login and OTP attempts, and string-built queries that allow injection. They come from agents optimizing for code that works rather than code that is safe.
How do I audit an AI-built app myself?
Run structured threat-modeling prompts with your own coding agent, one category at a time, starting with authorization and secrets. The agent reports findings with file and line references; fix them, re-run the prompt to confirm, then watch production for attacks on the same areas.
Do I still need a penetration test?
If you handle payments, health data or other sensitive data, a professional test is worth it before or soon after launch. Do the self-checks and an agent-assisted audit first so the tester's time goes to business-logic flaws, not missing auth checks.
How long does a security audit of an AI-built app take?
The 11 quick checks in this post take about an hour together. A category-by-category audit with your coding agent adds a run per category plus the time the fixes need, so most small apps can get through the high-risk categories in a day.
Recommended reading
Part 1 of the AI security audit series: set up an AI coding agent to audit your app locally, connect SecureNow, and run your first threat-model pass.
Jul 24Audit your app's security with the AI coding agent you already use. Find code vulnerabilities, turn them into runtime detections, and never upload code.
Jul 24Part 2 of the AI security audit series: audit login, sessions, MFA, password reset and magic links for account-takeover paths, then catch live attempts.
Jul 23