What actually separates a vibe coder from a real engineer?
It is not raw skill, effort, or how good the finished app looks. The gap is a specific, learnable set of engineering disciplines that AI coding tools do not apply by default: threat modeling, data modeling for change over time, thinking through failure modes, idempotency, observability, and an honest sense of what you don't know. A vibe coder and a senior engineer can prompt the exact same tool and get working code back. The difference shows up in what neither of them thought to ask the tool to check.
That's actually good news. If the gap were talent, it would take years to close. Because it's a checklist of specific habits and questions, it can be learned in weeks by anyone willing to look at the parts of the app that don't show up in a demo.
What vibe coders are genuinely better at (this is a real list, not a consolation prize)
Before the list of gaps, the honest part first, because most of what gets said about vibe coding online is either hype or contempt, and neither is accurate.
- Speed to a working demo. A vibe coder can go from idea to a clickable product in an afternoon. Most senior engineers, working alone, cannot match that speed, because they were trained to build the "correct" version first.
- Product intuition. People who vibe code tend to be closer to the actual user problem. They are building the thing they want to exist, not the thing that is architecturally elegant. That instinct is worth more than it gets credit for.
- Not precious about code. Vibe coders throw things away constantly. They will delete a whole feature and rebuild it in ten minutes because the AI makes that cheap. Engineers, including good ones, get attached to code they wrote by hand and resist deleting it. This is a genuine advantage.
- Willingness to ship something imperfect. A lot of engineering training produces people who never ship because it's "not ready." Vibe coders ship, then iterate against real feedback. That loop, on its own, teaches more than a semester of software design theory.
None of this is the problem. The problem is narrow: a specific set of things AI tools will not surface unless you ask, and vibe coders, by definition, usually don't know to ask.
What a senior engineer does that AI does not do for you
Ask any AI coding tool to "build a signup form" and it will build a signup form that works in the demo. Ask it to build one and it will not, on its own, threat-model the endpoint, think about what happens a year from now when your data model needs to change, or write a test for the one path where money moves. Those six things are the actual gap.
1. Threat modeling
Threat modeling means asking "who would want to abuse this, and how" before you ship it, not after someone does. A vibe-coded signup form gets built to handle a real user typing their real email. It rarely gets built to handle a bot submitting the form 50,000 times, or a logged-in user changing a URL parameter to see someone else's account. Engineers ask "what if the person using this is trying to break it" as a default posture, not a security afterthought.
2. Data modeling for change over time
AI tools are excellent at modeling data for the feature you're building right now. They are much weaker at modeling for the feature you'll need in six months. A vibe-coded schema often stores a user's "plan" as a single text column that gets overwritten on upgrade, losing all history. An engineer stores plan changes as an append-only history table, because someone will eventually ask "what plan were they on last March" and there needs to be an honest answer.
3. Failure modes
What happens when the payment provider times out. What happens when the database write succeeds but the email confirmation fails. What happens when the same request arrives twice because a mobile network retried it. AI-generated code overwhelmingly assumes the happy path: everything succeeds, in order, exactly once. Engineers design for the paths where something goes wrong, because in production, over enough volume, everything eventually does.
4. Idempotency
Idempotency means an operation produces the same result no matter how many times it runs. This matters most anywhere money or state changes: webhooks, payment confirmations, email sends. Stripe, for example, retries a webhook automatically if your server doesn't respond fast enough or returns anything but a success code. A handler that isn't idempotent will grant access, send a receipt, or add credit twice. This single gap causes a disproportionate share of vibe-coded production incidents, and it's covered in detail with real code further down.
5. Observability
When something breaks in a vibe-coded app, the first sign is usually a user emailing to say it's broken. When something breaks in an engineered app, an alert fires first. Observability means logging what happened, monitoring whether the system is healthy, and alerting a human before the damage compounds. It's unglamorous, it never shows up in a demo, and it's the difference between a 10-minute incident and a lost weekend.
6. Knowing what you don't know
This is the hardest one to teach and the most important. A senior engineer, looking at an unfamiliar system, can point to the exact three places they'd want to double-check before trusting it: auth boundaries, anything touching money, anything touching another user's data. A newer builder, vibe coding or otherwise, tends to trust the whole thing equally, because it all came from the same confident-sounding source. Calibration, knowing which 10% of the code deserves 90% of your scrutiny, is arguably the single highest-leverage skill in this entire list.
The same feature, two ways: a signup and payment flow
Abstractions are easy to agree with and hard to apply. So here is one real feature, a signup form that collects payment, built the way a vibe-coded MVP typically ships it, and built the way a senior engineer ships it. Same user-facing outcome. Very different app underneath.
| Concern | How a vibe-coded version usually ships | How an engineered version ships |
|---|---|---|
| Database access | Row-level security is off, or a broad policy like using (true) slipped through because it made the demo "just work." | RLS is on, with a policy scoped to auth.uid() = user_id, tested by attempting to read another user's row and confirming it fails. |
| Payment keys | The Stripe secret key ends up in a client-exposed environment variable (anything prefixed NEXT_PUBLIC_ or bundled into a front-end build) because that's what made the checkout call succeed fastest. | The secret key lives server-side only, referenced from an API route or server action the browser never sees, and the publishable key is the only one shipped to the client. |
| Signup abuse | No rate limiting. The signup endpoint accepts unlimited requests, so a script can create thousands of accounts or hammer the payment API. | Rate limiting at the edge (by IP and by email) plus a CAPTCHA or equivalent challenge on repeated failures. |
| Webhook handling | The webhook trusts the request body without verifying the signature, and processes the event every time it's delivered, including retries. | The signature is verified against the raw request body, and the event ID is recorded so a retry is a no-op instead of a double-charge or double-grant. |
| Session cookies | The session token sits in localStorage or a cookie without httpOnly/secure/SameSite set, readable by any script on the page. | The session lives in an httpOnly, secure, SameSite=Lax cookie, invisible to client-side JavaScript. |
| Tests | Zero automated tests. The checkout path was "tested" by clicking through it once during development. | An automated test suite covers the money path specifically: a real test card completes checkout, a declined card fails cleanly, a webhook replay doesn't double-grant access. |
The database policy, in actual code
This is the single most common finding in AI-built apps on Supabase: row-level security either disabled entirely, or enabled with a policy too broad to do anything.
-- disabled entirely, or a "temporary" policy that never got tightened
alter table public.orders disable row level security;
-- or, RLS is "on" but this policy defeats the purpose
create policy "allow all" on public.orders
for select using (true);
alter table public.orders enable row level security;
create policy "users read their own orders"
on public.orders for select
using (auth.uid() = user_id);
create policy "users insert only their own orders"
on public.orders for insert
with check (auth.uid() = user_id);
-- verify it: log in as user A, try to select user B's order by id.
-- it must return zero rows, not a permissions error and not the row.
The webhook handler, in actual code
Payment webhooks are retried automatically by design, Stripe, PayPal, and most processors will re-send an event if your server doesn't answer fast enough or returns a non-2xx status. A handler that isn't built for that will process the same "payment succeeded" event more than once.
// app/api/webhooks/stripe/route.js
export async function POST(req) {
const event = await req.json(); // trusts the raw body completely
if (event.type === "checkout.session.completed") {
await grantAccess(event.data.object.customer_email);
// if Stripe retries this event, access gets granted again
}
return new Response("ok");
}
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // server-only, never NEXT_PUBLIC_
export async function POST(req) {
const sig = req.headers.get("stripe-signature");
const rawBody = await req.text();
let event;
try {
event = stripe.webhooks.constructEvent(
rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return new Response(`signature check failed: ${err.message}`, { status: 400 });
}
// idempotency: Stripe can and will redeliver. Record the event id
// and skip anything already processed, so a retry is a safe no-op.
const seen = await db.processedEvents.findUnique({ where: { id: event.id } });
if (seen) return new Response("ok");
if (event.type === "checkout.session.completed") {
await grantAccess(event.data.object.customer_email);
}
await db.processedEvents.create({ data: { id: event.id } });
return new Response("ok");
}
Neither version is more code. The engineered version is maybe fifteen extra lines. But those fifteen lines are the difference between a payment system and a payment system that occasionally charges someone once and grants access to two other people's retried webhook events.
Which one are you, right now? An honest self-assessment
No judgment either way, this is a diagnostic, not a verdict. Go through it plainly.
- Do you know, right now, without checking, whether row-level security is even turned on for your database?
- Could you open your deployed site's page source or network tab and confidently say no secret key is visible in it?
- If your payment webhook fired twice for the same event, do you know what would happen, without guessing?
- Is there a single automated test that runs before you deploy, on the path where a customer pays you?
- If the app went down at 3am, would you find out from a monitor, or from a customer's email?
- Can you name, off the top of your head, the three riskiest files in your codebase?
If most of those made you pause rather than answer instantly, that's the gap, in six questions. It's specific, it's checkable, and every item on that list is something you can learn to answer this month.
The gap is closeable, and closing it doesn't mean abandoning how you build
Nothing above requires giving up AI tools or starting over with a computer science textbook. It requires learning to ask your AI tool six additional questions on top of "build this feature": how would someone abuse this, how does this data need to look in a year, what happens when this step fails, can this run twice safely, how will I know if this breaks, and which part of this should I trust the least. That's a prompting habit, not a personality trait, and it's exactly what the next article in this series walks through step by step.
Frequently asked questions
Is vibe coding real coding?
Yes. Vibe coding produces real, working software, often faster than traditional methods for a first version. What it doesn't automatically produce is the engineering discipline underneath: threat modeling, safe data modeling, and failure handling. That layer has to be learned or added separately, it isn't a byproduct of prompting well.
Do I need a computer science degree to close this gap?
No. The specific gaps that cause AI-built apps to fail (row-level security, secrets handling, webhook idempotency, indexing, testing the money path) are learnable in weeks through deliberate practice and the right prompts, not a four-year degree. A degree teaches breadth; this gap is narrow and specific.
Should I stop using AI coding tools to get better at engineering?
No, keep using them. The fix isn't less AI, it's asking the AI different questions: have it explain the security and failure-mode tradeoffs of what it just wrote, instead of only asking it to write more code. The tool is the same, the prompts change.
How do I know if my app has these problems right now?
Check the self-assessment above, or get an outside diagnostic. Independent research has found the vast majority of AI-built apps carry at least one real security issue, so the base rate for "probably fine" is low enough that it's worth an actual check rather than a guess.