Field guide

Your vibe coded app is missing API auth for one of three reasons

None of them are “you forgot to think about security.” One of them is a trailing newline. One is a missing fetch header. One is a key that should never have left the server. Each fails in a different way, each is fixed in a different file, and the most common one is the one almost nobody writes about.

M
Matthew Diakonov
8 min
Direct answer (verified 2026-05-01)

Three causes, in order of frequency: (1) the AI wrote your API key into .env with a trailing \n, every request 401s, and the workaround was to remove the auth check; (2) the AI scaffolded fetch() from a React component with no Authorization header because nothing in its context told it to; (3) a service-scoped key was copy-pasted into client code. mk0r addresses each at the agent system-prompt and provisioner level: trailing-newline rule at src/core/vm-claude-md.ts:292, scoped Resend key at src/core/service-provisioning.ts:134, host-side ID token verify at src/lib/auth-server.ts:40.

Cause 1: a trailing newline in your .env

This is the most common cause and the one almost nobody names. When an AI is writing a key into .env, the natural shell pattern is echo "KEY=value" >> .env. If the model emits the value across a newline, or if the heredoc it picked terminates with a stray \n, the value that lands in the file is re_abc123\n, not re_abc123. The dotenv loader reads it verbatim. Every fetch the app makes with that key in the Authorization header gets a 401 from the upstream API, because the upstream API is trying to match a key that does not have a newline at the end.

What happens next is the loop that breaks the app. The user hits 401, asks the AI to “fix the auth error,” the AI cannot see that the bug is in the file write step (it is reading the env back through process.env where the value still looks correct in a console.log), and the path of least resistance becomes “remove the auth check on the route so the request goes through.” The app now has no API auth. The 401 is gone. The team ships it. Six weeks later, someone on X notices the unauthenticated route and quote-tweets it.

What the broken .env actually looks like, in cat -A

mk0r installs an explicit rule about this into the agent that builds your app. It lives at src/core/vm-claude-md.ts lines 292 through 310, which gets baked into the /root/.claude/CLAUDE.md inside every E2B sandbox. The section is titled “Common Pitfall: Trailing Newlines in Environment Variables.” It tells the agent: when writing API keys, tokens, or secrets to .env files, always check for and strip trailing \n characters. It then shows the wrong pattern (echo with a literal newline before the closing quote) next to the right pattern (printf without one), and instructs the agent to .trim() values read from environment or user input before using them in API calls or headers. The rule is short, written in plain English, and lives where the agent is guaranteed to read it on every turn.

The fix the agent is told to use

Cause 2: a fetch() with no Authorization header

The second cause is that the AI generated the client without ever attaching an auth header to its outbound calls. This is a context problem, not a knowledge problem. The model knows what an Authorization: Bearer header is. It does not put one on the fetch unless either the prompt mentions auth, the surrounding code does, or the system prompt tells it to. In a one-shot prompt like “build a Pomodoro app with a backend that saves my sessions,” none of those three are present. The fetch goes out with credentials: ‘include’ if you are lucky, and nothing if you are not.

mk0r’s host code does not have this bug, because the host forces the boundary at src/lib/auth-server.ts line 40. The function requireAuthOrError(req) pulls the Authorization: Bearer header off the request, slices off the Bearer prefix, hands the rest to getAuth(app).verifyIdToken(idToken), and either returns a verified user or a 401 Response. Every mutating route in the codebase calls it as line one. Grep for it: /api/vm/, /api/billing/, /api/chat/, /api/cookie-bridge/, /api/analytics/, /api/transcribe/. The pattern is a single import and a one-liner at the top of each route handler.

The agent inside the VM does not get this for free, but it gets told. The installed backend-services skill (in src/core/vm-claude-md.ts around line 1196 onward) names every pre-provisioned service and which env vars it lives behind. When the agent generates a server route to call Resend, it knows the key is in process.env.RESEND_API_KEY server-side and that the client should not see it. The Vite boundary (only VITE_* vars are exposed via import.meta.env) is the second guardrail. Together they push the agent toward server-mediated calls instead of direct client fetches with embedded keys.

Cause 3: a full-scope key in the bundle

Cause three is the one that turns into a Twitter bug bounty. You asked the AI to wire up a service. It needed a key. The prototype builder you used (or you, copy-pasting) gave it the “easy” key, the one with full scope. The AI dropped it next to the rest of the config, did not distinguish server-only from client-safe, and the bundle now ships it to every visitor. People view-source, find a string starting with sk_ or service_role, and the post writes itself.

The structural fix is the one mk0r picks at provisioning time. Open src/core/service-provisioning.ts and read provisionResend starting at the POST to https://api.resend.com/api-keys. The body is three fields: a name, a permission: "sending_access" on line 134, and a domain_id: undefined. sending_access is the narrowest scope Resend offers: send mail, nothing else. List audiences, no. Read sent messages, no. Delete an audience, no. The user never has the option to widen this scope; the provisioner picks it for them at the only moment that matters.

The same shape applies to the rest of the per-app stack. The Neon connection URI in DATABASE_URL is for a per-app role and a per-app password against a project that contains exactly one database for this session. The GitHub repo created at m13v/mk0r-{slug} is private. The PostHog token in VITE_POSTHOG_KEY is the project’s ingestion-only key, which is meant to live on the client. The dangerous one is DATABASE_URL, and Vite refuses to expose it to the client because its name does not start with VITE_. Wrong scope, wrong place, two layers of friction stop it from reaching the bundle.

Three failure modes, three fixes, all named in source

Trailing \n in .env
fetch() with no Authorization
Full-scope key in bundle
mk0r agent CLAUDE.md + provisioner
trailing-newline rule
requireAuthOrError
permission: sending_access

What is wired by default when you build on mk0r

The aggregate effect of those three fixes is that, on a fresh mk0r app, none of the three failure modes can trigger by accident. The trailing-newline rule lives in the agent’s instructions. The scoped key is the only key the user ever has. The Vite + host boundary keeps the dangerous key off the client. The host’s mutating routes are gated by Firebase ID token verification. The checklist below is what is true on the first turn, before any prompt has been typed.

What ships pre-wired, before the user types anything

  • An anonymous Firebase uid, signed by Google, verifiable server-side via getAuth().verifyIdToken.
  • A Neon Postgres role and password scoped to one database for this session, in DATABASE_URL.
  • A Resend API key with permission: 'sending_access' only. No list, no read, no delete.
  • A private GitHub repo at m13v/mk0r-{slug}, never public-by-default.
  • An installed agent rule against trailing newlines in .env writes (vm-claude-md.ts:292-310).
  • requireAuthOrError on every host-side mutating route, including /api/vm/*, /api/billing/*, /api/chat/*.
  • A Vite client/server boundary that refuses to expose any var whose name does not start with VITE_.

How to triage your own app right now

If you landed here because someone is laughing at your app on X, here is the order to check. It takes about three minutes.

  1. Check the env file for trailing newlines. cat -A .env. Anywhere a key value is followed by \n$ instead of just $, the value is corrupted. Rewrite it with printf, not echo. If the auth error goes away after this, the auth check you removed earlier should be put back.
  2. Grep for fetch() with no Authorization header. rg "fetch\\(" -A 5. Anywhere you see a fetch where the second-arg options object does not have a header named Authorization (and the route is supposed to be authenticated), you have cause two. Add the header from the user’s ID token.
  3. Grep the client bundle for service-scoped keys. Build the project, then rg "sk_|service_role|admin_token" dist/. If anything matches, that key is now public. Rotate it immediately on the upstream service, then move it to a server route the client can call.
  4. Verify auth on your routes. Pick a mutating route and call it from curl with no auth header. If the response is 200, the route is unauthenticated. Add a token check at the top of the handler before anything else.

Want to walk through your app with someone who has read these files?

Book 20 minutes. We will open mk0r.com together, look at the three files mentioned above (vm-claude-md.ts, service-provisioning.ts, auth-server.ts) in real source, and then point the same checklist at whatever app you came in with.

Frequently asked questions

Why does my vibe coded app's API have no auth in the first place?

Three causes, in order of how often I see them. First, the AI wrote a real API key into .env with a trailing newline, so every request it sends with that key gets a 401, and the path of least resistance for the next iteration was to remove the auth check from the route. Second, the AI scaffolded a fetch call from a React component without any Authorization header pattern, because nothing in its context told it to add one and the prompt did not mention auth. Third, the AI copied a service-role or admin-scoped key into client-side code because the prototype builder you used handed you one with no warning that it should never leave the server. The first cause is the loudest in practice and the one almost no other guide mentions.

Is the trailing newline thing actually that common?

Yes. It is common enough that mk0r ships an explicit rule about it inside the agent's installed CLAUDE.md. Open src/core/vm-claude-md.ts and read lines 292 through 310. There is a section literally titled 'Common Pitfall: Trailing Newlines in Environment Variables' that tells the agent: when writing API keys, tokens, or secrets to .env files, always check for and strip trailing \n characters. AI models frequently leave a trailing newline when outputting variable values, which causes silent auth failures, 401 errors, and broken API calls. The rule then shows the wrong way (echo with a literal newline before the closing quote) and the right way (printf with no trailing newline). That section was not added speculatively. It was added because keys with \n at the end are a recurring source of broken auth in vibe coded apps.

How do I tell whether my own broken auth is the trailing-newline bug or something else?

Cat the env file inside the running process and pipe it through cat -A. Anywhere you see $ at the end of a key value, the value has a trailing newline. The clean version reads VAR=value$ at the end of the line; a busted one reads VAR=value\n$ on its own. You can also do a quick repl check: console.log(JSON.stringify(process.env.MY_KEY)) and look for a literal \n inside the quoted string. If the key looks fine but auth still fails, move to cause two: search the codebase for fetch( and grep for the absence of any header named Authorization on the lines around it. If both look clean, you are probably in cause three, which is harder: a key that should never have been on the client.

What does mk0r actually do differently when it provisions a key?

It scopes it. Look at src/core/service-provisioning.ts at line 134 inside provisionResend. The body of the POST to https://api.resend.com/api-keys is a JSON object with three fields: a name, a permission of 'sending_access', and a domain_id of undefined. That permission is the narrowest scope Resend offers: the key can send mail and nothing else. It cannot list audiences, cannot delete audiences, cannot read sent message contents. So even if the generated app accidentally exposes that key, the blast radius is mail-sending only, against a Resend audience that mk0r owns. Most prototype builders hand the user a full-scope key by default; mk0r picks the smallest scope at provisioning time and the user never has the option to widen it.

What about the Postgres connection string and other keys, are those scoped too?

Yes, in different ways. The Neon connection URI written into DATABASE_URL is for a per-app role with a per-app password, scoped to a project that contains exactly one database for this session. There is no shared role across apps, no shared password, and no shared schema. If a generated app leaks DATABASE_URL, the worst case is one app's data, not every mk0r user's data. The GitHub repo created at provisioning time is private by default (POST /user/repos with private: true). The PostHog key in VITE_POSTHOG_KEY is the project's ingestion-only key, which is meant to be public anyway and cannot read events. So the only key whose exposure is genuinely scary is DATABASE_URL, and the question for that one is whether the agent ever puts it into client code, which gets to the next FAQ.

Does mk0r's agent ever put a server-only key into the client bundle?

Vite is the boundary. Inside the VM the project is a Vite + React project, which means import.meta.env exposes only the env vars whose names start with VITE_. DATABASE_URL, RESEND_API_KEY, and the Neon role password do not start with VITE_, so they are not exposed to the client at build time. The agent's installed backend-services skill (in vm-claude-md.ts around line 1207) explicitly tells it: client-side code uses import.meta.env, server-side or build-time code uses process.env. When the agent wires Resend, it writes a server endpoint that holds RESEND_API_KEY and a client fetch that calls that endpoint without ever seeing the key. The Vite boundary is not a security feature on its own, but combined with that rule it keeps the dangerous keys server-side by default.

Where is auth actually enforced on the host side, between mk0r.com and the VM?

At src/lib/auth-server.ts line 40, in a function called requireAuthOrError. Every API route that mutates persistent state imports this and calls it as the first line of the handler. The function pulls the Authorization: Bearer token off the request, calls Firebase Admin's verifyIdToken to confirm it is a valid Firebase ID token, and returns either a VerifiedUser or a 401 Response. You can grep the codebase to confirm the coverage: routes under /api/vm/, /api/billing/, /api/chat/, /api/cookie-bridge/, /api/analytics/, /api/transcribe/ all call it. The anonymous Firebase uid created on landing-page mount is enough to pass this check, so no signup is required, but every mutating call is still authenticated against a real verifiable token, not a session cookie or a shared key.

If the prototype is anonymous and the auth is invisible, am I really 'authenticated' or is this security theater?

It is real, just lazy. The anonymous Firebase uid is a genuine Firebase user with a uid, an issued ID token signed by Google, and a server-verifiable signature. The host can prove cryptographically that two requests came from the same anonymous user, and it uses that to bind the user to their Firestore session, their E2B sandbox pairing, and their provisioned services. The 'theater' fear is reasonable but lands on a different question: this only protects mk0r's own routes. Anything the agent generates inside the VM is not automatically protected by this; if you ask the agent to expose a public endpoint with no auth, it will, and that endpoint will be public. So 'is mk0r protected' and 'is the app the agent built protected' are two questions with the same answer pattern (verify the token) but two different code paths.

If I want the generated app itself to have proper API auth, what is the cheapest path?

Tell the agent to add it explicitly, and reference the three nouns it understands. Say: 'gate every server route behind a Firebase ID token; on the client, attach Authorization: Bearer <token> using the user property from useAuth(); on the server, call admin.auth().verifyIdToken on the request header.' The agent already knows about Firebase from the host context, and it has Anthropic's Claude tool stack to wire it. The cheaper path is to skip auth entirely until publish, while you are still iterating on UI, and only ask for auth when the app is about to be exposed to anyone other than you. Most vibe-coded prototypes hit this question one or two iterations before publish and the answer is fine to defer until then.

mk0r.AI app builder
© 2026 mk0r. All rights reserved.