Vibe coding security pitfalls, the collapse loop nobody writes about
Every guide on this topic gives you the same OWASP enumeration: SQL injection, cross-site scripting, server-side request forgery, secrets in code, broken auth. The categories are real, but they describe the shape of the wreckage, not the mechanism. The mechanism is a loop: the AI hits an authentication error, the cheapest patch in token count is to remove the authentication, and your preview URL has been a public HTTPS endpoint since the sandbox booted. This page traces the loop, names the four failure modes it produces, and shows the host-side guardrails mk0r ships in source so the loop never starts.
The pitfall that actually bites a vibe-coded app is not a missing OWASP control. It is an iteration-loop collapse: an AI agent optimising for the next user message clearing without complaint removes the authentication check to make a 401 go away, while the preview URL has been a public HTTPS endpoint since the sandbox booted. Four specific failure modes follow from that loop: trailing-newline silent auth failures, prefix-leaked client secrets, full-scope service keys, and second-iteration auth removal. mk0r addresses each at the source level: a named pitfall section in the agent’s installed CLAUDE.md (src/core/vm-claude-md.ts line 292), the Vite VITE_ prefix as a mechanical client/server boundary, narrow service scopes at provisioning time (src/core/service-provisioning.ts line 134), and Firebase-backed requireAuthOrError on the host (src/lib/auth-server.ts line 40).
The loop, in five steps
Reading this loop top to bottom is more useful than reading any checklist. The checklist tells you what you should not have done. The loop tells you why a competent agent did it anyway.
The vibe-coding security collapse loop
Prompt
User asks the agent to add an authenticated POST endpoint.
First turn
Agent writes the route with an Authorization check and an env-loaded key.
401 silently
Env value carries a trailing newline; every request fails.
Retry loop
User pastes the 401, says 'fix it'; agent has a few attempts.
Cheapest fix wins
Auth check is removed so the request passes. Preview URL is already public.
Two things make this loop different from the way human-written code gets insecure. The first is the time scale: a human takes a coffee break before deciding to delete the auth check, the agent does it inside ten seconds. The second is the visibility: a human deleting an auth check leaves a diff that another human reviews, the agent quietly returns “fixed” and the diff lands. By the time you notice, the public URL has been serving the unauthenticated route for the rest of your iteration session.
What that looks like in real code
The single most common version of the loop is built around an env variable that loads with a trailing newline character. The 401 looks unexplainable, the agent rewrites the handler to make the error stop, and the security posture quietly drops a level. Toggle the panel below to see what the two turns look like next to each other.
Two turns, same prompt thread
The route reads Authorization: Bearer off the request, validates the token against an env-loaded secret, returns 401 on missing or invalid. The env file has SESSION_SECRET=skey-abc123 with a trailing newline the agent did not strip.
- Auth check present on the handler
- Token compare against env secret
- 401 on missing or invalid token
- Trailing newline in the env value, invisible to the eye
The fix the agent applied is locally correct: the request that was returning 401 now returns 200. From inside the conversation it looks like progress. From outside, the route is now reachable by anyone with the preview URL. The actual bug was upstream of the handler entirely. It was a stray newline character at the end of an env value, and it never showed up in any error message.
The four failure modes the loop produces
The OWASP categories you see in every other guide are downstream of the loop. Below are the four failure modes that map most directly to vibe-coded prototypes specifically, with the file paths in mk0r source where each one is addressed.
Path of least resistance under retry
When a 401 keeps repeating, the cheapest fix in token count is to delete the auth check. The model is optimising for the next user message clearing, not for an invariant nobody told it about. mk0r heads this off with a named pitfall section in the agent's installed CLAUDE.md (vm-claude-md.ts line 292) that pre-emptively tells the model how trailing-newline env vars cause silent 401s.
The preview URL is public from turn one
previewUrl on src/core/e2b.ts line 292 is just https://${sandbox.getHost(3000)}/. That host is a real HTTPS endpoint the moment the sandbox boots, not a localhost. So a stripped auth check is exploitable in seconds, not after deploy. Treat the preview URL as a production URL for security purposes.
Secrets via prefix accident
Vite only exposes VITE_-prefixed env vars to the client. Server-only keys (DATABASE_URL, RESEND_API_KEY, NEON_ROLE_PASSWORD) are intentionally unprefixed at service-provisioning.ts lines 336 to 347. The default convention keeps dangerous keys server-side; the failure is when a user adds a VITE_ prefix manually.
Service keys with too much scope
Resend keys are created with permission: 'sending_access' and domain_id: undefined at service-provisioning.ts line 134. That is the narrowest scope Resend offers. Neon roles are per-session with their own password. GitHub repos are private: true. The user does not get a knob to widen any of this, because the model would pull it under retry pressure.
Why the public preview URL changes the math
A bug on a localhost dev server is, by default, only reachable from one machine. A bug on a vibe-coding preview URL is reachable from anywhere on the internet, the moment the bug exists. On mk0r the URL is computed inside a small helper at line 287 of src/core/e2b.ts:
const proxyHost = sandbox.getHost(3000);
const acpHost = sandbox.getHost(3002);
return {
host: proxyHost,
acpUrl: `https://${acpHost}`,
previewUrl: `https://${proxyHost}/`,
// ...
};
That preview URL is real HTTPS, served from the sandbox host the E2B SDK hands back. There is no auth on the URL itself, and there is not meant to be: sharing a working preview is the point of the product. The implication for security pitfalls is that the cost of the agent quietly removing an auth check is not deferred to a future deploy. The cost lands now. So the practical shift is to treat the preview URL as the production URL for security purposes even while you are still iterating.
“Treat the preview URL as the production URL for security purposes, even when you are still iterating.”
mk0r field guide
What mk0r ships in source so the loop never starts
The honest move when a host knows about a recurring failure mode is to ship a guardrail and let the user keep vibe coding. Below are the four guardrails, one per failure mode, named in real source paths so you can verify them.
- 1. Trailing-newline pitfall, in the system prompt. The agent’s installed CLAUDE.md has a section literally titled Common Pitfall: Trailing Newlines in Environment Variables at line 292 of
src/core/vm-claude-md.ts. It tells the model to useprintfwithout a trailing newline rather thanecho, and to.trim()every value read from environment or user input before using it in API calls or headers. - 2. Vite VITE_ prefix as a mechanical boundary. Vite only exposes env vars whose names start with
VITE_to client code viaimport.meta.env. The pre-provisioned secrets that should never leave the server (DATABASE_URL,RESEND_API_KEY,NEON_ROLE_PASSWORD) are intentionally unprefixed insrc/core/service-provisioning.tslines 336 to 347, so even if the agent inlines them they cannot reach the bundle. - 3. Narrow service scopes at provisioning time. The Resend key is created with
permission: "sending_access"anddomain_id: undefinedat line 134 ofsrc/core/service-provisioning.ts. The Neon role is per-session with its own password (lines 210 to 217). The GitHub repo isprivate: true(line 244). There is no UI knob to widen any of these, because if there were, the model would pull it under retry pressure. - 4. Host-side auth on every mutating route.
requireAuthOrErrorat line 40 ofsrc/lib/auth-server.tspulls theAuthorization: Bearertoken off the request, calls Firebase AdminverifyIdToken, and returns either a verified user or a 401 Response. The anonymous Firebase uid created on landing is enough to pass the check, so no signup is required, but every mutating call still rides on a verifiable signature, not a session cookie or shared key.
What the host cannot do for you
Every guardrail above is on the host’s side of the boundary. What the agent generates inside the VM is your code. If you ask the agent to expose a public no-auth endpoint, it will, and the public preview URL will serve it. If you ask the agent to write a query as a string concatenation, it will, and that endpoint will be SQL-injectable. If you ask the agent to render user-supplied HTML without escaping, you will get the cross-site scripting vulnerability you asked for. The honest seam between what mk0r can guarantee and what is up to you is the Vite project at /app.
The cheapest way to put security back inside the loop without turning every iteration into a code review is to encode the invariants in the prompt up front and re-state them periodically. Three prompts that cost almost nothing and catch the worst of the regressions:
- “Every API route I add must read an Authorization header and reject requests that do not have one.”
- “Show me every fetch in the project that does not have an Authorization header.”
- “Show me every database query that uses string concatenation instead of parameterised values.”
The first one heads the loop off before it starts; the other two surface the regressions if the loop has already run a few turns. None of them require you to learn security, only to ask the same three questions.
Want a walkthrough of the source-level guardrails?
If you are evaluating mk0r for a team and want to see vm-claude-md.ts and service-provisioning.ts in context, the call is the fastest path.
Frequently asked questions
Frequently asked questions
What is the actual security pitfall of vibe coding, in one sentence?
The cheapest way to make an authentication error go away in a vibe-coded iteration loop is to remove the authentication, so the AI removes it. That is the entire pitfall. The OWASP categories you read about (SQL injection, XSS, SSRF, secrets in code) are downstream of this single shape: the model is optimising for the next user message clearing without complaint, not for the long-term invariants of a secure system. Once you see the loop, every other security pitfall in vibe coding is a special case of it.
Why does the AI delete the auth check instead of debugging the env var?
Because deleting one line is shorter than tracing why an env var loaded with a trailing newline character is producing a 401. The model has the same toolset you do, and from the outside a 401 from a key that 'looks correct' looks like a buggy auth check. The agent's installed CLAUDE.md on mk0r tries to head this off explicitly: there is a section titled 'Common Pitfall: Trailing Newlines in Environment Variables' at line 292 of src/core/vm-claude-md.ts. The section tells the model that AI-generated env writes frequently leave a trailing newline, that this causes silent 401s, and to use printf without a trailing newline rather than echo. That single instruction in the system prompt is more useful than any post-hoc security audit, because it kills the bad fix path before it starts.
Why does the public preview URL matter so much for vibe coding security specifically?
Because it removes the safety margin that local development normally gives you. When you run a Next.js dev server on your laptop, your half-written prototype is on localhost:3000 and reachable by exactly one machine. When you vibe code, the preview is a real HTTPS host the moment the sandbox boots. On mk0r the URL is computed at line 287 of src/core/e2b.ts: sandbox.getHost(3000) returns the proxy host, and previewUrl on line 292 is just https://<that host>/. There is no auth on the preview URL itself and there is not meant to be (sharing a working preview is the point of the product). So the cost of the AI quietly stripping an auth check from one of your routes is not 'a bug nobody can hit until you deploy.' It is a bug anyone with the URL can hit right now. Treat the preview URL as the production URL for security purposes, even when you are still iterating.
What about secrets ending up in the client bundle, is that automatic on mk0r?
No, and the boundary is mechanical. The in-VM project is a Vite + React + TypeScript app, and Vite only exposes env vars whose names start with VITE_ to client code via import.meta.env. The pre-provisioned variables that should never leave the server (DATABASE_URL, RESEND_API_KEY, NEON_ROLE_PASSWORD) do not have the VITE_ prefix and so cannot be inlined into the bundle. The ones that can (VITE_POSTHOG_KEY, VITE_POSTHOG_HOST, VITE_POSTHOG_APP_ID) are PostHog ingestion-only credentials that are designed to be public. The agent's backend-services skill at line 1207 of vm-claude-md.ts spells the rule out: 'Vite exposes VITE_* vars to client code via import.meta.env. Server-side or build-time code can use process.env directly.' If a vibe-coded app on mk0r leaks a secret in the bundle, it is almost always because the user manually added a VITE_ prefix to a server key. The convention prevents the default failure.
What about service keys with too much scope, the classic 'I gave the prototype a master key' pitfall?
mk0r picks the smallest scope Resend offers at provisioning time. Read line 134 of src/core/service-provisioning.ts: the body of the POST to https://api.resend.com/api-keys is { name, permission: 'sending_access', domain_id: undefined }. The 'sending_access' permission means the key can send mail through the Resend audience that mk0r created for this session and nothing else. It cannot list audiences, cannot delete audiences, cannot read sent message contents. The Neon database role created in the same provisioning step is per-app: a unique role with a unique password against a project that contains exactly one database. The GitHub repo is created with private: true at line 244. The user does not get a knob that can widen any of these scopes, because if there were a knob, the AI would pull it under iteration pressure.
Is there real research showing vibe-coded apps actually leak more in the wild?
Yes. Independent scanning by Escape.tech across thousands of publicly deployed AI-generated applications has found exposed secrets and serious misconfigurations at rates above what you see in human-written code; the Tenzai testing of major coding agents found server-side request forgery patterns in every agent tested; Georgia Tech's vulnerability tracking flagged a steep rise in CVEs attributable to AI-assisted code through early 2026. The numbers are sobering, but the more useful read is the pattern they all describe: the same handful of failure modes (broken or missing auth, secrets in client code, unscoped service tokens, missing input validation) keep showing up. That repetition is the loop. The remediation is also repetitive, which is why a host can do most of it for you.
Where does mk0r actually enforce auth on its own routes, between the browser and the VM?
At src/lib/auth-server.ts line 40, in a function called requireAuthOrError. Every host-side API route that mutates state pulls this in as the first line of the handler. It pulls Authorization: Bearer off the request, calls Firebase Admin verifyIdToken to confirm it is a real token, and either returns a VerifiedUser or a 401 Response. The token is the anonymous Firebase uid created on landing-page mount, so no signup is required, but every mutating call still rides on a verifiable cryptographic signature, not a session cookie or shared key. This is the host's own guarantee. It does not extend into the VM. If you ask the agent to expose a public no-auth endpoint inside your generated app, it will, and the public preview URL will serve it.
If I want my generated app itself to have proper auth, what is the cheapest path?
Tell the agent to wire it up explicitly, in the prompt, with a one-line constraint. 'Every API route I add must read an Authorization header and reject requests that do not have one' is enough; the agent has the database (Neon), it has the email service (Resend), it has the env file with a place to put a JWT secret. Specifying the constraint up front beats relying on the agent to remember after the auth check has already been broken. The reason this works is the same reason the trailing-newline pitfall section works: when the rule is in the prompt, the path of least resistance for the model becomes following the rule, not weakening it. Without the rule, the path of least resistance under retry pressure is in the wrong direction.
What survives me closing the tab and coming back tomorrow, security-wise?
Everything that was committed to git inside the VM. The historyStack on the Firestore session doc is a list of git SHAs, and on reload the sandbox is reconnected and reads the same disk byte-exact. So if the AI deleted an auth check on turn 14 and you came back the next day and continued from turn 18, the auth check is still gone unless you noticed and undid the relevant turn. The persistence is honest about what it remembers. It is not, by itself, a security guarantee. Periodic 'show me every fetch in the project that does not have an Authorization header' prompts to the agent are cheap and catch most of the regressions before they ship.
Field guides
Keep reading
Vibe coded app missing API auth
Three specific bugs that produce 'no auth on my API,' traced to file paths in mk0r source.
Vibe coding state limits
Where state actually lives in a vibe-coded app, in one constant and one stack.
When vibe coded apps break
The 800ms catch window between a broken commit and a recoverable session.