You built an app with an AI tool, it works when you click through it, and now you're wondering whether it's safe to let real people sign up. That's the right question to ask, and today you get a way to answer it: twelve checks, each with the failure it prevents and a test you can run yourself in about five minutes.
Is vibe coding safe? For a prototype, usually yes. For an app with real users, real data or real money, it's safe only after you've checked the places where generated code tends to break: secrets, who can read whose data, input from the browser, abuse, payments, errors, dependencies and tests. This article gives you one test for each.
If you're a beginner, don't panic about the length. Read the short story of each check, run the test, and write down what failed. You don't have to understand every fix today; you have to know where the holes are. If you already ship code for a living, the scripts are the part worth copying, and the advanced bits sit in the callouts and in the section on running the whole thing with an AI agent.
If you haven't read it yet, What Is Vibe Coding? Where It Works and Where It Breaks explains the idea and shows one bug in detail. This one is the practical follow-up: the full inspection.
Note: Tested with: Node 26.13 on 2026-11-11. Every script in this article was run against a small local test server that has two versions of each route, the kind of code an AI tool hands you on the first try and a fixed one. The outputs you see below are copied from those runs.
A demo and a product are different things
Here's the problem in your own words: "It works. I clicked everything. Nothing crashed. So is it done?"
It works for you. That's the catch. When you test your own app, you are the nicest user it will ever have. You sign in with one account, you type sensible things into the forms, you click the buttons in the order you designed them, and you never try to read somebody else's data, because why would you? Real traffic is not that polite. Some of it is curious people, some of it is bots, and a small part of it is someone actively looking for the one route you forgot to protect.
Think about buying a house. A house can look finished: fresh paint, nice kitchen, lights on. An inspector doesn't admire the living room. The inspector turns on every tap at once, flips the breakers, looks under the sink and climbs into the attic. The question isn't whether the house is pretty. It's what happens when the house is used hard. That's the job we're doing today, on your app.
Is this a real problem or just fear? Here are two sources worth reading. Veracode, a company that sells code security scanning (so read it as a vendor's report), tested code from over 100 language models and says AI-generated code introduced risky security flaws in 45% of tests. And the OWASP Top 10:2025, the list most security people use as a starting point, puts Broken Access Control at number one, which is exactly the "Emily can read James's order" bug you'll see in a minute. Software supply chain failures are number three. Both show up in this checklist.
Why AI-built apps break in the same places
Why does this happen? Good question, because it's not that the AI is stupid.
A model writes the code you asked for. If you asked for "a page that shows the order", you get a page that shows the order. It works. What you didn't say, it fills in with an assumption, and the most common assumption is the friendly one: whoever asks for order 1002 must be the owner of order 1002. The model can't see your users. It can't know that one of them will change a number in the address bar.
I'll be honest about how I see it. I check what AI writes as far as I can, because it can hallucinate or assume things it should not. And when the output isn't what I imagined, usually I could have prompted better. The AI is a machine running maths. It can't know what's in my head, and it definitely can't know the bad intentions of a stranger who hasn't shown up yet.
There's also a second cause, and it's ours, not the model's. We test by clicking. Clicking only tests the happy path, the one where everybody behaves. Every check below is a way to stop being the nice user for five minutes.
I didn't think about any of this when I started. I never asked what happens when a thousand people have profiles in my app: how their accounts are protected, how a password reset works, what I do when my own bug hurts them. Those questions come for every app that gets real users. Better to meet them now, on your terms.
How to use this checklist
The checklist has twelve items in eight groups: secrets, access, input, abuse, payments, failure, dependencies and tests. Each item answers three questions. What breaks if you skip it? What's the five-minute test? Where do you learn the fix?
Most tests are tiny Node scripts. You need Node 20 or newer: the scripts use fetch, which is built into Node, and a recursive folder read that older versions lack. Create a folder called checks in your project, save the scripts there, start your app locally with npm run dev, and run each one with node checks/<name>.mjs. The scripts talk to http://localhost:3000 by default; set BASE_URL if your app runs somewhere else.
Some tests need you to be signed in. Here's the trick. Create two test accounts in your own app, say Emily (demo@example.com) and James. Sign in as Emily, open the browser's developer tools, go to the storage or application tab, find the session cookie and copy its name and value. That string, like name=value, goes into the EMILY_COOKIE variable. Now your script is Emily.
Warning: Only run these tests against apps you own, on your own machine or your own staging site. Sending forged requests or hammering the login of someone else's app is not a test, it's an attack, and it can be illegal. Your app, your accounts, your responsibility.
Secrets: the keys that must never leave the server
A secret is anything that proves to another service that you are you: a Stripe secret key, a database password, a private key, an email provider's API key. Whoever has it can act as your app. They can read your database, issue refunds, send email in your name. So secrets live in exactly one place: environment variables on the server.
1. No secret lives in your code
What breaks: an AI tool, asked to "connect Stripe", sometimes pastes the key right into a file so the example works. You push to GitHub. If the repository is public, the key is public. If it's private, the key is still in every copy and every backup of the code, forever.
The test: scan your project for the shapes secrets usually have.
checks/find-secrets.mjs
import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; const root = process.argv[2] ?? "."; const patterns = [ /sk_live_[0-9a-zA-Z]{10,}/, // Stripe secret key, live /sk_test_[0-9a-zA-Z]{10,}/, // Stripe secret key, test /whsec_[0-9a-zA-Z]{10,}/, // Stripe webhook signing secret /-----BEGIN [A-Z ]*PRIVATE KEY-----/, // any private key /postgres(ql)?:\/\/[^:\s]+:[^@\s]+@/, // database URL with a password in it ]; for (const file of readdirSync(root, { recursive: true })) { if (/(^|[\\/])(node_modules|\.git)([\\/]|$)/.test(file)) continue; const path = join(root, file); if (statSync(path).isDirectory()) continue; readFileSync(path, "utf8").split("\n").forEach((line, i) => { if (patterns.some((p) => p.test(line))) console.log(`${path}:${i + 1}`); }); } Line by line. The first two lines bring in Node's file tools. root is the folder to scan; it's the current folder unless you pass another one. The patterns array is the heart of the script: each line is a regular expression, a search pattern, for one kind of secret. sk_live_ and sk_test_ are how Stripe secret keys start, whsec_ is how Stripe webhook signing secrets start, BEGIN ... PRIVATE KEY is the header of any private key file, and the last one catches a database address with a password inside it, like postgresql://user:password@host.
The loop walks through every file in every subfolder (that's what recursive: true does). It skips node_modules, which is other people's code, and .git, which is git's own storage. For every other file it reads the text, splits it into lines and prints the file name and line number of any line that matches. It never prints the secret itself, because you don't want your keys in a terminal history either.
Here's what it printed on the test project:
terminal
$ node checks/find-secrets.mjs .env.local:1 .env.local:2 lib/stripe.ts:1 .next/static/chunks/page-abc.js:1 Read the result like this. Hits in .env.local are fine, as long as git ignores that file; check that .gitignore contains .env*.local or .env*. Any other hit is a leak. lib/stripe.ts has a key typed straight into the code. And the hit in .next/static is the next check's problem, so hold that thought.
Warning: If a real key was ever committed, deleting the line is not enough. GitHub's own guide says the first step is to revoke or rotate the secret, because rewriting history may not remove copies in forks, clones and caches. Rotate first, clean up second. Your old key must stop working; that's the only fix that holds.
Read GitHub's guide on removing sensitive data from a repository once, before you need it. And search your history too, not only today's files: a key that was in the code last month and deleted since is still sitting in an old commit.
2. No secret reaches the browser
What breaks: in Next.js, an environment variable whose name starts with NEXT_PUBLIC_ is copied into the JavaScript that every visitor downloads. The Next.js docs say it plainly: the value is "inlined into any JavaScript sent to the browser". Variables without the prefix stay on the server.
Here's how a secret ends up public. A component in the browser needs data, the code reads process.env.STRIPE_SECRET_KEY, and it comes back undefined, because the browser can't see server variables. You paste the error into the chat. The quickest change that makes the error go away is renaming the variable to NEXT_PUBLIC_STRIPE_SECRET_KEY. It works. And your secret key is now in a file anyone can open with View Source.
The test: build the app, then look for the value of every server secret inside the files the browser gets.
checks/bundle-leak.mjs
import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; const secrets = readFileSync(".env.local", "utf8") .split("\n") .map((line) => line.match(/^([A-Z0-9_]+)=["']?(.+?)["']?$/)) .filter((m) => m && !m[1].startsWith("NEXT_PUBLIC_") && m[2].length >= 8) .map((m) => ({ name: m[1], value: m[2] })); let leaks = 0; for (const file of readdirSync(".next/static", { recursive: true })) { const path = join(".next/static", file); if (statSync(path).isDirectory()) continue; const text = readFileSync(path, "utf8"); for (const s of secrets) { if (text.includes(s.value)) { console.log(`LEAK ${s.name} is inside ${path}`); leaks++; } } } console.log(`${secrets.length} server secrets checked, ${leaks} found in browser files`); The first statement reads .env.local and turns it into a list. For each line, the regular expression pulls out a NAME=value pair and strips quotes. The filter keeps only server secrets: names that don't start with NEXT_PUBLIC_ (those are public on purpose) and values of at least eight characters, so a setting like PORT=3000 doesn't count as a secret and doesn't match half the internet.
Then the loop goes through every file in .next/static. That folder is what next build produces for the browser: every chunk of JavaScript a visitor can download. For each file and each secret it asks one question, does this file contain this exact value? If yes, it prints which secret and where. At the end you get a count.
Run npm run build first, then node checks/bundle-leak.mjs from the project root. On the test project:
terminal
$ node checks/bundle-leak.mjs LEAK STRIPE_SECRET_KEY is inside .next/static/chunks/page-abc.js 2 server secrets checked, 1 found in browser files The fix is never "hide it better". The fix is moving the work that needs the secret to the server: a route handler or a server action calls Stripe, and the browser only gets the result. If a value really must be in the browser, like a publishable Stripe key or an analytics id, it's not a secret, and the service that issued it says so in its docs.
Access: every request proves whose data it is
This is the big one. If you do only one check today, do this one.
3. Emily can't read James's order
Some words first. Authentication answers "who are you?" It's the login. Authorization answers "are you allowed to touch this?" Generated code is usually good at the first and quietly skips the second. The route checks that someone is signed in, then returns whatever id was asked for.
Security people call this an IDOR, an insecure direct object reference. Fancy name, simple bug: the id in the address is the only thing standing between a user and somebody else's data. Change 1001 to 1002 and you're reading a stranger's order, email and home address.
Click around in the demo below. You're signed in as Emily. Ask for her order 1001, then try 1002, then switch the server to the fixed version and try again.
Live demo: Two accounts, one URL: the IDOR check. It runs right in the page, so try it in the original article.
Notice that both servers check the login. The generated one is not missing security; it's missing one question. The fixed one asks it: who owns this order? And when the answer is "not you", it says 404, not found, instead of 403, forbidden. Why? Because 403 tells a curious visitor that order 1002 exists. 404 tells them nothing.
The test for your real app needs your two test accounts. As James, create something: an order, a note, a profile, whatever your app stores. Write down its id from the address bar or the network tab. Then run this as Emily:
checks/two-accounts.mjs
const BASE = process.env.BASE_URL ?? "http://localhost:3000"; const emily = process.env.EMILY_COOKIE; // copied from the browser, signed in as Emily const jamesOrder = process.env.JAMES_ORDER; // an id that belongs to James const res = await fetch(`${BASE}/api/orders/${jamesOrder}`, { headers: { cookie: emily }, }); const body = await res.text(); if (res.ok) { console.log(`FAIL Emily can read James's order (status ${res.status})`); console.log(body.slice(0, 200)); process.exitCode = 1; } else { console.log(`OK the server refused with status ${res.status}`); } BASE is your app's address. emily is Emily's session cookie, the name=value string from the developer tools. jamesOrder is the id of something that belongs to James. The fetch call asks for James's thing while carrying Emily's cookie, which is exactly what a browser would send if Emily typed that address herself.
res.ok is true for any status from 200 to 299, meaning the server handed the data over. That's a fail, so the script prints the status and the first 200 characters of what leaked, and sets the exit code to 1, which matters later when you run checks automatically. Any other status means the server refused, and that's what you want.
terminal
# against the route as generated $ node checks/two-accounts.mjs FAIL Emily can read James's order (status 200) {"id":1002,"owner":"james","item":"Team plan","total":3000,"address":"12 Elm Street"} # against the fixed route $ node checks/two-accounts.mjs OK the server refused with status 404 Repeat it for every route that takes an id: orders, invoices, messages, files, settings, and every update and delete, not only reads. The fix is one idea: the database query includes the owner, so the row can only be found by the person it belongs to. What Is Vibe Coding shows that fix on a real route handler, and Production-Grade Code covers the version that bites on updates: checking who, but not whose.
Tip: Using Supabase, where the browser talks to the database directly? Then the database itself has to say no. Supabase's docs are clear: a table in an exposed schema without Row Level Security "is readable and writable by any role with a grant on it", and they ask you to enable RLS on every such table. Check each table in the dashboard. A table with RLS switched off and the public key in your front end is the IDOR bug with the door already open.
The Supabase line above comes from its Row Level Security guide. If you're on Supabase auth, the Supabase authentication guide shows where the policies go.
4. The server decides prices and roles, never the browser
What breaks: the checkout form sends the plan and the price, and the server charges whatever price arrives. Or the sign-up form sends role: "user" and the server saves whatever role arrives. Anyone can edit a request before it leaves the browser; it takes no special tools. So a price or a role coming from the browser is a suggestion from a stranger.
The test for this one is the same script as the next check, so let's look at them together.
Input: garbage in, a polite 400 out
5. Every input is checked at the door
What breaks: the server assumes the body is what the form would have sent. Send it something else, a number where text belongs or no JSON at all, and the code crashes halfway through. A crash is a 500 error, and on many setups the error message, sometimes with the full stack trace, goes straight back to the visitor.
The test: send your own checkout or sign-up endpoint four requests that a normal form would never send.
checks/bad-input.mjs
const BASE = process.env.BASE_URL ?? "http://localhost:3000"; const cookie = process.env.EMILY_COOKIE; const cases = [ ["price from the browser", { plan: "pro", price: 1 }], ["extra field", { plan: "pro", role: "admin" }], ["wrong type", { plan: 42 }], ["not JSON at all", "hello"], ]; for (const [name, body] of cases) { const res = await fetch(`${BASE}/api/checkout`, { method: "POST", headers: { "content-type": "application/json", cookie }, body: typeof body === "string" ? body : JSON.stringify(body), }); const text = await res.text(); const leaks = /\bat .+:\d+:\d+|prisma|ECONNREFUSED|TypeError/i.test(text); console.log(`${name.padEnd(24)} ${res.status} ${text.slice(0, 60).replace(/\s+/g, " ")}${leaks ? " <- LEAKS INTERNALS" : ""}`); } cases is a list of pairs: a name for the output, and the body to send. The first sends a price of 1 cent. The second sneaks in role: "admin". The third sends a number where the plan name should be. The fourth isn't JSON at all, just the word hello.
The loop posts each one as Emily, with the content-type header saying JSON. If the body is already a string, it goes as it is; otherwise JSON.stringify turns the object into JSON text. Then the script reads the answer as text and runs one more regular expression over it, looking for things that should never reach a visitor: a stack trace line (at something:12:34), the word prisma, a database connection error, a TypeError. Each result prints on one line: name, status, the start of the response, and a warning if internals leaked. The replace call squeezes line breaks into spaces so a multi-line stack trace stays on one line.
Here's the version the AI wrote, then the fixed one:
terminal
# as generated price from the browser 200 {"plan":"PRO","amount":1,"role":"user"} extra field 200 {"plan":"PRO","amount":1200,"role":"admin"} wrong type 500 TypeError: body.plan.toUpperCase is not a function at Se <- LEAKS INTERNALS not JSON at all 500 SyntaxError: Unexpected token 'h', "hello" is not valid JSON <- LEAKS INTERNALS # fixed price from the browser 200 {"plan":"pro","amount":1200} extra field 200 {"plan":"pro","amount":1200} wrong type 400 {"error":"Unknown plan"} not JSON at all 400 {"error":"Invalid JSON"} Look at the first two lines of the generated version. The order went through for 1 cent, and Emily is now an admin. Two lines of output, two disasters. In the fixed version the price comes from the server's own price list, the extra field is ignored, and the broken requests get a 400 with a short, boring message. Boring is the goal.
That's checks 4 and 5 in one script. The fix has a name: validation at the trust boundary. The boundary is the line where data comes from somewhere you don't control. Zod at the Trust Boundary shows how to describe what you accept in one schema and refuse everything else, and Login Form Validation: Why You Check Twice explains why the check in the browser is for the user's comfort and the check on the server is for your safety.
Abuse: what happens when someone tries a thousand times
6. Public endpoints have a rate limit
What breaks: your login, sign-up, password reset and contact form accept unlimited requests. A script can try thousands of passwords on one account, create thousands of fake accounts, or send thousands of emails through your contact form, and your email provider bills you or blocks you.
A rate limit is a bouncer with a counter. Five tries from the same place in a minute, fine. The sixth gets told to wait, with the status code 429, Too Many Requests.
The test: try to log in twenty times in a row with a wrong password.
checks/rate-limit.mjs
const BASE = process.env.BASE_URL ?? "http://localhost:3000"; const counts = {}; for (let i = 0; i < 20; i++) { const res = await fetch(`${BASE}/api/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "demo@example.com", password: "wrong-password" }), }); counts[res.status] = (counts[res.status] ?? 0) + 1; } console.log(counts); counts is an empty object that will count how many times each status came back. The loop sends twenty login attempts for demo@example.com with a wrong password, one after another. counts[res.status] ?? 0 means "the count so far, or zero if this status is new", plus one. At the end you see the tally.
Point the address at whatever your sign-in form actually posts to; the network tab in the developer tools shows it when you submit the form once. Some auth libraries also expect extra fields or a CSRF token, so if every answer is the same error, check that your request looks like the one the form sends.
terminal
# no limit { '401': 20 } # with a limit of 5 per minute { '401': 5, '429': 15 } Twenty 401s means twenty honest "wrong password" answers and no limit at all. Five 401s and fifteen 429s means the bouncer is working.
Do it for every public endpoint that does real work: login, sign-up, password reset, contact, newsletter, anything that sends an email or costs money. Rate Limiting Your Next.js API shows how to build the limit, and why a counter kept in memory isn't enough when your app runs on several serverless instances at once.
Payments: a webhook must prove who sent it
7. A fake "payment succeeded" is rejected
A webhook is a message another service sends to your app when something happens. Stripe calls your /api/webhooks/stripe address to say "this customer paid", and your app switches on the paid plan. Here's the catch: that address is public. Anyone on the internet can send a POST to it.
So Stripe signs every message with a secret only you and Stripe know, and puts the signature in the Stripe-Signature header. Your code must check it before believing anything. Stripe's docs put it bluntly: without verification, an attacker could send fake webhook events to your endpoint to trigger things like fulfilling orders or granting access.
The test: send your own webhook a payment that never happened.
checks/fake-webhook.mjs
const BASE = process.env.BASE_URL ?? "http://localhost:3000"; const fakeEvent = { id: "evt_fake", type: "checkout.session.completed", data: { object: { id: "cs_fake", customer_email: "demo@example.com", amount_total: 0 } }, }; const res = await fetch(`${BASE}/api/webhooks/stripe`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": "t=1,v1=forged" }, body: JSON.stringify(fakeEvent), }); console.log(res.status, res.ok ? "FAIL the fake payment was accepted" : "OK rejected"); fakeEvent has the shape of a real Stripe event: an id, a type that says a checkout was completed, and a data object with an email and an amount of 0. The request carries a Stripe-Signature header too, but the signature is made up. A handler that verifies signatures will see that v1=forged doesn't match and refuse. A handler that just reads the JSON will happily switch on the plan.
terminal
# handler that only parses the JSON 200 FAIL the fake payment was accepted # handler that verifies the signature 400 OK rejected If you get a 200, stop and fix it before launch, because this is free premium access for anyone who reads this paragraph. Stripe Webhooks: Verify the Signature or Lose Money walks through the fix, including the part that trips everyone up: the signature is computed over the raw body, so you must verify before anything parses or reformats it.
Failure: when something goes wrong, you find out first
Everything breaks at some point. A production app isn't one that never fails; it's one where failure is small, quiet for the user and loud for you.
8. Errors tell the user little and you a lot
You already ran this test. Look back at the bad input output: "TypeError: body.plan.toUpperCase is not a function" went straight to the visitor. A stack trace tells an attacker your file names, your libraries and sometimes your database. It also tells a normal user nothing useful.
The fix has two halves. The visitor gets a calm message and a way forward. You get the full error, with the details, in your logs. In the browser, error boundaries catch a crashed component so one broken widget doesn't blank the whole page; React Error Boundaries Done Right shows where to put them in the App Router.
9. You can find yesterday's error in one minute
The test is not a script, it's a stopwatch. Trigger the wrong type case from check 5 again. Now open your host's logs (on Vercel, the project's Logs tab) and find that exact error. Start the timer.
If you find it in under a minute, with the route, the time and the message, you're fine. If you're scrolling through a wall of text, or the error isn't there at all because the code caught it and said nothing, fix that now. A user will write to you one day saying "it didn't work yesterday around lunch", and this is the moment you'll need it.
Structured Logging in Next.js show...