DEV Community

Cover image for Vibe Coding Is Fine Until It Isn't: A 12-Point Production Checklist
CodeFromScratch
CodeFromScratch

Posted on Originally published at codefromscratch.org

Vibe Coding Is Fine Until It Isn't: A 12-Point Production Checklist

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}`);
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" : ""}`);
}
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 shows the small change that makes logs searchable: one JSON line per event, with a request id you can follow from start to end.

10. You can roll back in two minutes, and you've tried it

What breaks: you deploy on a Friday evening, the new version has a bug that blocks every login, and you don't know how to put the old version back. On Vercel there's a button for that, Instant Rollback. Read the rollback docs for two details that matter: on the free Hobby plan you can roll back to the immediately previous deployment only, and environment variables are not rolled back with the code.

The test: do it once on purpose, on a calm day. Deploy a tiny harmless change, roll it back, check the site, then undo the rollback. Time yourself. Under two minutes, and you know where the button is when your hands are shaking.

One thing no button can roll back is your database. If the bad version changed the shape of your data, putting the old code back can make it worse. Rollback Strategies for Small Teams explains how to change a database so that the previous version of the code still works with it.

Dependencies: every package is real, needed and patched

11. No package the AI made up, and none with known holes

What breaks: models sometimes suggest packages that don't exist. Researchers measured this in a paper at USENIX Security 2025 and found that the average share of hallucinated packages was at least 5.2% for commercial models and 21.7% for open-source models, with 205,474 unique made-up names in their tests. A made-up name is harmless while nobody owns it. The danger starts when someone registers that exact name with bad code inside, and your install pulls it in.

The test: ask the npm registry about every package in your package.json.

checks/check-packages.mjs

import { readFileSync } from "node:fs";

const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const names = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
const DAY = 24 * 60 * 60 * 1000;

for (const name of names) {
  const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2F")}`);
  if (res.status === 404) {
    console.log(`MISSING  ${name}`);
    continue;
  }
  const data = await res.json();
  const days = Math.floor((Date.now() - Date.parse(data.time.created)) / DAY);
  console.log(`${days < 90 ? "NEW    " : "ok     "}  ${name} (first published ${days} days ago)`);
}
Enter fullscreen mode Exit fullscreen mode

The script reads package.json and merges dependencies and devDependencies into one list of names. For each name it asks registry.npmjs.org, the public npm registry, for that package's record. Scoped packages like @types/node need the slash written as %2F in the address, which is what the replace does.

A 404 means the package doesn't exist at all: MISSING. Otherwise the record has a time.created field, the date the package was first published, and the script turns it into days. Anything younger than 90 days gets the label NEW. New isn't bad by itself; new and unknown and suggested by an AI is worth a look. Open its page, check who publishes it, whether it has a real repository and real users.

terminal

$ node checks/check-packages.mjs
ok       next (first published 5602 days ago)
ok       zod (first published 2439 days ago)
ok       @types/node (first published 3830 days ago)
MISSING  this-package-should-not-exist-cfs-xyz-123
Enter fullscreen mode Exit fullscreen mode

Then two more things, without a script. Run npm's built-in audit, which sends your dependency list to the registry and reports known vulnerabilities; with --omit=dev it looks only at what ships to production. And go through package.json line by line asking "do I use this?" AI tools add packages for one experiment and never remove them. Every package you don't need is code you didn't read, running with your permissions.

Tests: the three paths that hurt the most are guarded

12. Login, money and permissions have tests

You don't need tests everywhere. You need them where a bug costs real money or exposes real people: who can sign in, what gets charged, and who can see what. Everything you just did by hand becomes a test that runs on every change, so a future "small fix" from you or from an AI can't quietly reopen the hole.

Here's check 3 turned into a test with Node's built-in test runner, which the Node docs mark as stable. Nothing to install.

tests/access.test.mjs

import { test } from "node:test";
import assert from "node:assert/strict";

const BASE = process.env.BASE_URL ?? "http://localhost:3000";
const as = (cookie) => ({ headers: cookie ? { cookie } : {} });

test("Emily can read her own order", async () => {
  const res = await fetch(`${BASE}/api/orders/1001`, as(process.env.EMILY_COOKIE));
  assert.equal(res.status, 200);
});

test("Emily cannot read James's order", async () => {
  const res = await fetch(`${BASE}/api/orders/1002`, as(process.env.EMILY_COOKIE));
  assert.equal(res.status, 404);
});

test("a signed-out visitor gets nothing", async () => {
  const res = await fetch(`${BASE}/api/orders/1001`, as());
  assert.equal(res.status, 401);
});
Enter fullscreen mode Exit fullscreen mode

test comes from node:test and registers one test with a name and an async function. assert from node:assert/strict gives you assert.equal, which fails the test when two values differ. The as helper builds the fetch options: with a cookie it sends it, without one it sends no headers, so the same helper covers "signed in as Emily" and "signed out".

Three tests, three rules in plain English. Emily can read her own order, so 200. Emily can't read James's order, so 404. Nobody signed out gets any order, so 401, which means "sign in first". Notice the first test. It's there so the second one can't pass by accident: if the route were simply broken for everybody, the 404 test would pass and lie to you.

terminal

# against the route as generated
$ node --test tests/
✔ Emily can read her own order
✖ Emily cannot read James's order
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  200 !== 404

# against the fixed route
$ node --test tests/
✔ Emily can read her own order
✔ Emily cannot read James's order
✔ a signed-out visitor gets nothing
Enter fullscreen mode Exit fullscreen mode

node --test finds files ending in .test.mjs on its own. If your project already uses Vitest or Jest, write the same three tests there; the idea doesn't change. Add one test for the price (the server's price wins) and one for the webhook (an unsigned event is refused), and you've covered the three paths. Testing React Applications covers the rest of a testing strategy, once these are in place.

Running the whole checklist in 30 minutes with an AI agent

Here's the part people get backwards. They ask the AI "is my app secure?", get a confident "yes, with a few suggestions", and feel better. That's asking the cook whether the food is good.

The split that works: the AI does the mechanical part, you do the judgment. It's the same way I work with an AI agent on any task. We talk the project through first, we make a plan, then we go step by step: I give the steps, the AI writes the code, I check it.

For this checklist, the steps look like this:

  • Ask the agent for an inventory, not an opinion: every route and server action, the HTTP method, whether it requires a login, and the exact line where it checks who owns the data. Ask for "none" where there's no such line.
  • Read that list yourself. Every "none" on a route that takes an id is a candidate for check 3. This is the judgment part, and it's the part you can't hand over.
  • Ask the agent for a second list: every environment variable, where it's read, and whether that file can run in the browser. Compare it with what bundle-leak.mjs found.
  • Ask it to write the tests from check 12 for your real routes, then run them yourself and read the failures. A test you didn't watch fail once is a test you can't fully trust.
  • Run the scripts from this article in order. Write every FAIL into one list. Fix the list one item at a time, rerunning the matching script after each fix.

Thirty minutes is enough for the inventory and the scripts on a small app. Fixing what you find takes longer, and that's fine. The point of the first session is to know, not to be done.

Note: For advanced readers: put these scripts in CI. The ones that set an exit code (two-accounts.mjs, the node:test file) can fail a pull request; run them against a preview deployment with dedicated test accounts and seeded data, never against production users. Treat the inventory from the agent as a document you regenerate on every bigger change, because new routes are where new holes appear.

When vibe coding is completely fine

I don't want you to walk away thinking every AI-built thing needs a security audit. It doesn't.

A prototype you show to three friends is fine. An internal tool that only you use, on your own machine, is fine. A weekend experiment to learn how something works is great, actually; that's one of the best uses of these tools. Vibe code all of that freely.

The line is simple. The moment your app holds other people's data, takes money, sends email in your name, or does anything you can't take back, it's not a prototype anymore. That's when the checklist stops being optional. Before that line, speed wins. After it, this list wins.

So that's all it is

Look at the twelve checks again and you'll see they're really one idea, repeated: don't trust anything that comes from outside the server until you've checked it. The cookie says who is asking; check whose data it is. The body says the price; ignore it and use yours. The webhook says someone paid; check the signature. The package name in a suggestion; check it exists. The key in the code; make sure it never leaves.

Generated code mostly trusts. Your job is to add the places where it shouldn't. Once you see that, you stop needing a checklist for every new feature. You just ask, for each new route, "what's coming from outside, and where do I check it?"

What to be careful about, and the Plan B

These scripts are a floor, not a ceiling. Passing all twelve means you've closed the most common holes, not every hole. They don't test things like cross-site scripting, file uploads, or the security of the services you depend on. Don't write "secure" on your landing page because a script printed OK.

Another trap: testing with one account. Every access bug hides when you're signed in as the owner. Two accounts minimum, always, and ideally a third with an admin role so you can check that ordinary users can't reach admin routes.

And keep the outputs. A FAIL you fixed today can come back with the next generated change. That's why check 12 exists: the tests remember so you don't have to.

Now the Plan B, because a good programmer thinks a few steps ahead: scaling, security, reliability, and always a backup plan. Here's mine for this situation.

  • If you find a hole in an app that's already live, close the door first and investigate second. Roll back if the previous version was safe, or turn the route off until it's fixed.
  • If a secret leaked, rotate it now, then move it to an environment variable, then worry about git history.
  • If you take payments and you're not confident in your webhook and pricing code, use a hosted checkout page from your payment provider, where the price is set on their side, until you are.
  • If the checklist turns up more than you can fix alone, don't launch to the public yet. Invite a small group you know, fix things in order of damage, and ask someone more experienced to look at the access checks. Asking for help isn't weakness; plenty of people in real companies don't know everything either.

If your Plan B is "hope nobody notices", that's not a Plan B.

The checklist, on one screen

  • 1. No secret in the code. Test: find-secrets.mjs; only .env.local may match.
  • 2. No secret in the browser. Test: build, then bundle-leak.mjs finds 0.
  • 3. Every request checks whose data it is. Test: two-accounts.mjs refuses, on every route with an id.
  • 4. The server decides prices and roles. Test: bad-input.mjs, a price of 1 and role admin are ignored.
  • 5. Bad input gets a 400, not a crash. Test: bad-input.mjs, wrong type and non-JSON give 400.
  • 6. Public endpoints have a rate limit. Test: rate-limit.mjs shows 429s.
  • 7. Webhooks verify signatures. Test: fake-webhook.mjs is rejected.
  • 8. Errors don't leak internals. Test: no LEAKS INTERNALS in the bad-input output.
  • 9. You can find an error in a minute. Test: a stopwatch and your host's logs.
  • 10. You can roll back in two minutes. Test: roll back once on purpose.
  • 11. Every package is real, needed and patched. Test: check-packages.mjs, npm audit --omit=dev, and a read of package.json.
  • 12. Login, money and permissions have tests. Test: node --test passes, and you watched it fail first.

What to learn next

Start with check 3, the two-accounts test, on your own app tonight. It's the one most likely to find something real. Then read Production-Grade Code, which is the other side of this article: not how to inspect an app, but how to write it so these checks pass the first time.

If you're early in your journey and all of this felt like a lot, that's normal. Learning Web Development From Scratch With AI is about which of these concepts to learn first, so the AI becomes a fast pair of hands instead of the only one who knows how your app works.

Every check here is something you can understand, run and fix, one at a time. You can do this and you have to. If you weren't capable I wouldn't spend my time on you. Just don't give up and you'll always have me on your side.


This article first appeared on CodeFromScratch, where the live demos run right in the page. If you want the next one in your inbox, join the newsletter.

Top comments (0)