Security · AI-built apps

The vibe-coding security checklist

14 real risks, in order of how often they actually show up, each with the plain-English version, how to check whether you have it, and how to fix it with actual SQL and code. This is the checklist we run against every AI-built app before we call it safe.

Why this list exists

Most AI-built apps ship with at least one of these unresolved.

This is not a theoretical worst case. These are measured rates across more than a thousand production applications.

98%
of 1,072 scanned Supabase-backed vibe-coded apps had at least one security issue.
Symbiotic Security, 2026
16%
of those apps had a critical flaw exploitable without authentication.
Symbiotic Security, 2026
172
apps allowed unauthenticated record deletion through a public API key alone.
Symbiotic Security, 2026

Additionally: 62% of AI-built applications ship with critical security vulnerabilities at launch (OX Security, 2026). Every figure on this page is cited to its source, never invented.

How should I use this checklist?

Work through it in order. The first four items, row-level security, the anon key, client secrets, and authorization, cause the overwhelming majority of real incidents, and each one takes an afternoon, not a rewrite, to fix properly.

Each item below follows the same structure: the risk in plain English, a concrete way to check whether you have it, a concrete fix with real code or SQL, and a prompt you can hand an AI coding assistant to help close the gap. None of this requires a security background, it requires actually running the checks instead of assuming.

01

Row-level security missing or disabled on a table

Critical

If a table holding user data doesn't have row-level security enforced, anyone with your public API key, which is in every browser that loads your site, can potentially read, write, or delete every row in it. This is the single most common finding in scanned AI-built apps.

How to check

Run this against your Postgres database and read the qual column for every table holding user or business data:

select schemaname, tablename, policyname, qual
from pg_policies
where tablename = 'orders';

If a sensitive table has no rows at all, RLS was likely never enabled. If a policy exists but qual reads true, it's "enabled" in name only, it allows everyone.

How to fix it

The classic broken pattern, looks protected, isn't:

alter table orders enable row level security;

create policy "orders_select" on orders
  for select
  using (true);  -- true means "always allowed", to everyone

The actual fix, scoped to the record's real owner:

alter table orders enable row level security;

create policy "orders_select_own" on orders
  for select using (auth.uid() = user_id);

create policy "orders_insert_own" on orders
  for insert with check (auth.uid() = user_id);

create policy "orders_update_own" on orders
  for update using (auth.uid() = user_id)
  with check (auth.uid() = user_id);
Prompt to help
List every table in this database that stores user, order, payment, or
account data. For each one, tell me whether row-level security is
enabled and, if so, paste the exact policy. Flag any policy using
(true) or any sensitive table with no policy at all as CRITICAL.
02

Misunderstanding what the public anon key protects

High

The public "anon" key in Supabase, Firebase, or similar platforms is designed to be public, it ships in every browser bundle by default and that is normal. It is safe to expose only because the database, via RLS, is supposed to be the thing actually deciding what a request can touch, not the key.

How to check

Confirm two things separately: first, that only the anon/public key appears in your client-side code (search for service_role in your frontend, it should return zero results). Second, verify item 01 above, because the anon key's safety is entirely conditional on RLS being correct.

How to fix it

Never ship the service_role key, which bypasses RLS entirely, to any client-side code. It belongs only in server-side environment variables, used only in backend functions that themselves enforce their own authorization:

// server-only, never in a client bundle
const supabaseAdmin = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY // no NEXT_PUBLIC_ / VITE_ prefix
);
Prompt to help
Search this entire frontend codebase, including build output, for the
string "service_role". Tell me every file where it appears. Separately,
confirm which Supabase/Firebase key is used in client-side code versus
server-side code, and flag any place they might be mixed up.
03

Secrets shipped inside the client bundle

Critical

A Stripe secret key, an OpenAI key, or a database connection string pasted into frontend code is visible to anyone who opens browser dev tools or views the page source, no hacking required.

How to check

Build your app for production, then grep the actual built output, not your source files:

grep -rE "sk_live_|sk_test_|service_role|AIza[0-9A-Za-z_-]{35}" ./dist ./build ./.next 2>/dev/null

Also check that any environment variable holding a secret does not use a client-exposed prefix. Any variable named NEXT_PUBLIC_*, VITE_*, or REACT_APP_* gets bundled into client-visible JavaScript by design, that prefix is a promise to your bundler that the value is public.

How to fix it

If a grep above finds a live key, rotate it immediately, in this order: generate a new key in the provider's dashboard (Stripe: Developers → API keys → roll key; OpenAI: API keys → revoke and create), update your server-only environment variable, deploy, then revoke the old key last so nothing breaks mid-rotation. Move the call itself server-side:

// client calls your own server, never the third-party API directly
const res = await fetch('/api/create-payment-intent', { method: 'POST', body: ... });

// your server, holding the real secret key
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // server-only env var
Prompt to help
Search this entire repository and its built/compiled output for
hardcoded secrets: API keys, tokens, connection strings, passwords.
For each one found, tell me the file, the line, whether it appears in
the client bundle, and whether the environment variable holding it
uses a client-exposing prefix like NEXT_PUBLIC_, VITE_, or
REACT_APP_.
04

Authentication without authorization (IDOR)

Critical

A logged-in user is not the same thing as an authorized user. An endpoint can correctly confirm someone is logged in and still let them fetch, edit, or delete a record that belongs to someone else, simply by changing an ID in the URL.

How to check

Log in as user A, note the URL or ID for one of their records (an invoice, an order), then, still logged in as user A, request user B's record by ID directly. If it returns data, that's an IDOR (insecure direct object reference).

How to fix it

Vulnerable, checks login but not ownership:

app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
  res.json(invoice);
});

Fixed, ownership is part of the query itself, not a separate check that can be forgotten:

app.get('/api/invoices/:id', requireAuth, async (req, res) => {
  const invoice = await db.invoice.findFirst({
    where: { id: req.params.id, userId: req.user.id }
  });
  if (!invoice) return res.status(404).json({ error: 'not found' });
  res.json(invoice);
});
Prompt to help
Find every endpoint that fetches, updates, or deletes a record using
an ID from the URL or request body. For each one, show me whether the
database query itself includes the current user's ID as a filter, or
whether it trusts the ID alone. List every case that trusts the ID
alone as an IDOR risk.
05

No input validation at the boundary

High

Trusting the client is the root cause behind most of the bugs on this list. Anything a browser sends can be edited before it arrives, a form field, a hidden price, a quantity, a role.

How to check

Pick any endpoint that writes data and send it a request with an unexpected type, a negative number, or an extra field using curl or Postman. If your server accepts it without complaint, there's no validation.

How to fix it

Validate every request against an explicit schema before touching it:

import { z } from 'zod';

const CreateOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive().max(50),
  shippingAddress: z.object({
    line1: z.string().min(1).max(200),
    zip: z.string().regex(/^\d{5}(-\d{4})?$/)
  })
});

const parsed = CreateOrderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
Prompt to help
List every place this app accepts input from a client: form fields,
query params, JSON bodies, webhooks. For each one, tell me whether a
schema validates it before use, and write a validation schema for any
endpoint that's missing one, ranked by how sensitive the data is.
06

SQL injection via unsafe dynamic queries

Critical

Building a SQL string by concatenating user input lets an attacker send input that changes what the query actually does, up to and including reading or deleting your entire database.

How to check

Search your codebase for any raw query built with string concatenation or template literals containing a variable:

grep -rn "SELECT.*\${" --include="*.js" --include="*.ts" .
How to fix it

Vulnerable:

db.query(`SELECT * FROM users WHERE email = '${email}'`);

Fixed, parameterized so user input is always treated as data, never as code:

db.query('SELECT * FROM users WHERE email = $1', [email]);

Any ORM (Prisma, Drizzle, Sequelize) parameterizes automatically when you use its query builder correctly, the risk is almost always in a hand-written "raw" escape hatch.

Prompt to help
Search this codebase for every place a SQL query is built using string
concatenation or template literals with a variable inside them,
instead of parameterized queries or the ORM's built-in query builder.
List each one with the file and line.
07

XSS via unsafe HTML rendering

High

Rendering user-supplied content as raw HTML lets an attacker inject a script tag that runs in another user's browser, stealing their session or acting as them.

How to check

Search for the escape hatches that bypass a framework's automatic escaping:

grep -rn "dangerouslySetInnerHTML\|v-html" --include="*.jsx" --include="*.tsx" --include="*.vue" .
How to fix it

If you must render user-generated HTML (a rich text editor, a comment with formatting), sanitize it first:

import DOMPurify from 'dompurify';

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />

If the content is plain text, don't use the raw-HTML escape hatch at all, render it as text and let the framework escape it automatically.

Prompt to help
Find every place this app renders user-supplied content as raw HTML
(dangerouslySetInnerHTML, v-html, or similar). For each one, tell me
whether the content is sanitized first, and if not, propose the fix.
08

CORS misconfiguration

Medium

A wildcard CORS policy combined with credentials lets any website on the internet make authenticated requests to your API using a visitor's existing session.

How to check

Look at your CORS config for the combination of a wildcard origin and credentials enabled, that pairing should never appear together.

How to fix it

Vulnerable:

app.use(cors({ origin: '*', credentials: true })); // never combine these two

Fixed, an explicit allowlist of your real frontend domains:

app.use(cors({ origin: ['https://app.yourdomain.com'], credentials: true }));
Prompt to help
Show me the CORS configuration for every server in this codebase.
Flag any config that combines a wildcard origin with credentials
enabled, and propose an explicit allowlist based on our real
production domains.
09

No rate limiting on sensitive or expensive endpoints

Medium

Without rate limiting, a login endpoint can be brute-forced, and any endpoint that calls a paid API (OpenAI, a SMS provider) can be hit in a loop until your bill is enormous.

How to check

Send 50 rapid requests to your login endpoint or any AI/SMS-calling endpoint from a script. If none are rejected or slowed, there's no limiting in place.

How to fix it
import rateLimit from 'express-rate-limit';

app.use('/api/login', rateLimit({ windowMs: 60_000, max: 5 }));
app.use('/api/', rateLimit({ windowMs: 60_000, max: 60 }));

If you're on a serverless or edge platform, use its native rate limiting (Cloudflare, Vercel) or a hosted service like Upstash's rate limiter, which works across cold starts where in-memory limiting like the above does not.

Prompt to help
List every endpoint in this app that handles login, password reset, or
calls a paid third-party API. Tell me which ones currently have rate
limiting applied, and add appropriate limits to any that don't.
10

Unvalidated file uploads

High

Trusting a file's declared type or accepting unlimited file sizes lets an attacker upload an executable disguised as an image, or exhaust your storage and bandwidth with a single request.

How to check

Try uploading a renamed file (a .php or .exe file renamed to .jpg) through your upload form. If it's accepted, the check is trusting the filename or the client-declared content type, not the actual file.

How to fix it
const allowed = ['image/png', 'image/jpeg', 'application/pdf'];
if (!allowed.includes(file.mimetype) || file.size > 10 * 1024 * 1024) {
  return res.status(400).json({ error: 'invalid file' });
}
// verify actual file content matches its claimed type (magic bytes),
// store outside the webroot or in object storage behind a signed URL,
// never execute or serve uploaded files with the original filename
Prompt to help
Find every file upload endpoint in this app. For each one, tell me
whether it validates file type by actual content (not just the
client-declared mimetype or extension), enforces a size limit, and
stores files outside any directly executable or served path.
11

Webhooks with no signature verification

Critical

A webhook endpoint that trusts any request claiming to be from Stripe, without verifying it, lets an attacker fake a "payment succeeded" event and unlock paid features for free, or worse.

How to check

Send a raw POST request to your webhook URL with a fabricated payload, no signature header. If it's processed, verification is missing.

How to fix it
const sig = req.headers['stripe-signature'];
let event;
try {
  event = stripe.webhooks.constructEvent(req.rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
  return res.status(400).send('Webhook signature verification failed');
}
// only trust `event` from here on, never the raw parsed body

This pattern applies the same way to any provider's webhooks (Shopify, Twilio, GitHub), each has its own signature header and secret, but the shape of the fix is identical.

Prompt to help
Find every webhook endpoint in this codebase. For each one, tell me
whether it verifies the provider's signature before trusting the
payload, and add verification to any that don't, using the raw
request body, not the parsed JSON.
12

Known-vulnerable dependencies

Medium

AI coding agents pull in packages quickly and rarely check whether a dependency, or one of its own dependencies, has a known, published vulnerability.

How to check
npm audit --production
npx osv-scanner --lockfile package-lock.json
How to fix it

Run npm audit fix for non-breaking patches, and manually review and upgrade anything flagged as high or critical that requires a major version bump. Add the scan to your CI pipeline so new vulnerable dependencies get caught before merge, not months later.

Prompt to help
Run npm audit (or the equivalent for our package manager) and
summarize every high or critical finding: which package, which
vulnerability, and whether upgrading it is a safe patch or a breaking
change I need to test manually.
13

Logging that leaks personal data

Medium

Logging an entire request body "for debugging" often means passwords, card details, or full addresses end up sitting in plaintext in a log aggregator that far more people can access than your database.

How to check

Search your logging calls for anywhere the full request object or body is passed in directly, rather than specific, named fields.

How to fix it
// leaks whatever the client sent, including card fields, passwords
logger.info('order created', req.body);

// logs only what you actually need to debug
logger.info('order created', { orderId: order.id, userId: order.userId });
Prompt to help
Find every logging call in this codebase that logs a full request
object, request body, or user object rather than specific named
fields. List each one and propose a version that logs only what's
needed without including passwords, tokens, or payment details.
14

Backups nobody has actually restored

High

A backup that has never been restored is a hope, not a plan. Automated backups can silently fail, be misconfigured, or exclude a table nobody remembered to include, and you find out the day you actually need them.

How to check

Ask when your last successful restore drill happened. If the honest answer is "never," that's the finding.

How to fix it

Quarterly, restore your latest backup into a separate staging database and verify it, not just that the restore command succeeded, but that row counts and a few known records match what you expect:

pg_restore -d staging_db latest_backup.dump
psql -d staging_db -c "select count(*) from orders;"
Prompt to help
Write a step-by-step runbook for restoring our database backup into a
staging environment and verifying it, including specific queries to
confirm row counts and referential integrity look correct after
restore.

How can I tell if I've already been breached?

Check four things today, each takes a few minutes and any one of them alone is worth investigating immediately if it turns something up.

  • Auth logs. Check your auth provider's login history for logins from unexpected countries, unfamiliar devices, or a burst of failed attempts against one account.

  • Unexpected rows. Look for user, admin, or API-key rows created outside your normal signup or invite flow, especially any with elevated roles nobody remembers granting.

  • Key usage dashboards. Check Stripe, OpenAI, and Supabase usage or billing dashboards for request or spend spikes you didn't cause, that's often the first visible sign a key leaked.

  • Row counts. Compare your current row counts on key tables against your last known-good backup. A sudden, unexplained drop in a table's row count is a signal worth chasing down immediately, not writing off as a glitch.

If you find something

Rotate every key immediately, revoke active sessions, and get a second set of eyes on it before you decide it's contained. If you're not sure, treat it as active until proven otherwise. Our security audit starts with exactly this triage.

Free, no course attached

The rescue playbook.

Real prompts, security rules, and teardowns of broken AI-built apps, sent when we publish something worth your inbox. No fluff, no drip sequence.

One or two emails a month. Unsubscribe anytime.

Want a senior engineer to run this checklist for you?

We run this exact list, plus the deeper checks a checklist can't fully cover, against your actual codebase and infrastructure, and hand you a straight, prioritized findings list.

Questions

Common questions about vibe-coding security.

Is the public Supabase anon key safe to expose in my frontend?

Yes, but only if row-level security is correctly enforced on every table it can reach. The anon key is designed to be public, like a login form is public. It is safe specifically because Postgres, not the key, is supposed to be the thing deciding what any given request is allowed to touch. If RLS is disabled or set to using (true), exposing the key is the same as exposing the database.

How do I know if my app already has a broken RLS policy?

Run select schemaname, tablename, policyname, qual from pg_policies; in your Postgres database and read the qual column for every table that holds user data. If qual reads true, or a table with sensitive data has no rows in pg_policies at all, that table is unprotected regardless of what the frontend code implies.

What is the difference between authentication and authorization?

Authentication confirms who someone is, that they have a valid login session. Authorization confirms what that specific, authenticated person is allowed to do to a specific record. An app can have perfect authentication and still let any logged-in user read anyone else's invoice by changing an ID in the URL, that is an authorization failure, commonly called IDOR, and it is one of the most common findings in AI-built apps.

How can I tell if my app has already been breached?

Check your auth provider's login logs for logins from unexpected countries or a burst of failed attempts, check your database for user or admin rows created outside your normal signup flow, and check your Stripe, OpenAI, and Supabase dashboards for request or spend spikes you did not cause. Any one of those on its own is worth investigating immediately.

Do I need a full security audit if I run this checklist myself?

Running this checklist closes the majority of what independent scans find in AI-built apps, and you should do it regardless. A professional audit is still worth it before you handle real customer data or money at scale, because it tests your specific business logic and access patterns an AI or a generic checklist cannot fully anticipate.