Vibe coding with Claude — how to ship secure code with Claude Code
Claude Code is the best vibe-coding partner available right now. Here are the three things it gets wrong every time, and the system prompt that fixes them.
Claude Code is, by a margin, the best vibe-coding partner available at the moment of writing. It plans before it writes, it refuses to ship code it can’t run, it asks before deleting your work, and it’s notably less likely than Cursor or Bolt to invent a function that doesn’t exist. We use it daily.
It also makes the same three security mistakes over and over. Not because it’s careless — because the defaults of “ship fast, fix later” tend to win every time you don’t actively push back. This post is the three patterns, and the system prompt we use to keep Claude from shipping them.
What Claude gets right
Some context first. The reason we recommend Claude Code over the alternatives for security-sensitive work isn’t that it’s more secure — it’s that it’s more honest about what it just did. When Claude finishes a task, it tells you what it changed. When something fails, it says so instead of pretending it worked. That auditability is the foundation everything below builds on. If you can’t tell what your AI just did, you can’t review what it just did.
Claude is also unusually good at refusing to do unsafe things when you ask it for them. Tell it “skip the auth check, I’ll add it later” and it’ll push back. Tell Cursor or Codex the same thing and they’ll just do it.
What Claude still ships
OK, the mistakes. These are patterns we see repeatedly in code Claude writes for vibe-coded apps:
1. It puts secrets in NEXT_PUBLIC_* when you ask for “an environment variable I can use in components”
The problem: Claude, like every other LLM, has read a thousand Next.js tutorials where people use NEXT_PUBLIC_* for variables they wanted accessible in React components. It learned that pattern. When you ask “store the OpenAI API key in an environment variable that my chat component can use,” it’ll write NEXT_PUBLIC_OPENAI_API_KEY. Your key is now in every visitor’s browser.
Same thing happens with Supabase. NEXT_PUBLIC_SUPABASE_ANON_KEY is fine — that’s what it’s for. But Claude will sometimes write NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY if you asked for “the admin key in components.” That one shouldn’t exist anywhere near the browser.
The fix: explicitly tell Claude which variables are server-only. The system prompt at the bottom of this post does this.
2. It writes Supabase Row-Level Security policies that look right but aren’t
Ask Claude “enable RLS on this table and make sure users can only see their own rows” and you’ll get a policy that looks reasonable. About 70% of the time it works. The other 30% it does one of:
- Uses
auth.email()instead ofauth.uid(), which matches anyone with the same email (rare collision but real). - Forgets that
INSERTandUPDATEneed separate policies fromSELECT. You can read your own row, but you can also create rows for other users. - Writes
USING (true) WITH CHECK (true)because “the user said they wanted authenticated users to access it.”truemeans anyone authenticated, including a brand-new anon signup, can read every row.
These bugs don’t fail loudly. They fail when an attacker discovers them.
The fix: never trust an RLS policy you didn’t write yourself or read end-to-end. Test from a logged-out browser session. See our Supabase RLS post for the test pattern.
3. It builds admin pages without server-side authorization
You ask Claude for an admin dashboard. It writes a React page at /admin that checks if (user.role !== 'admin') return <NotAuthorized /> in the component. The check exists, but it’s in the wrong place. The data was already loaded server-side before the user check ran. Hit the API route directly with curl and the JSON comes back regardless of who you are.
This isn’t a Claude-specific bug — every AI does this. We mention it under Claude because Claude is generally honest if you ask it “is this protected server-side?” — but you have to remember to ask.
The fix: every privileged route gets auth at the API layer, not just the UI. The system prompt below makes this an invariant.
The system prompt we actually use
Drop this into your CLAUDE.md (or your project’s root context, depending on how you’ve set things up). It pre-empts the three patterns above and a handful of related ones:
SECURITY DEFAULTS — apply to every change in this project unless I override.
ENV VARS
- Any variable holding a secret (API keys, service role keys, database passwords, signing secrets, OAuth client secrets) is server-only. Never use a NEXT_PUBLIC_, VITE_, or PUBLIC_ prefix for these.
- Before adding any env var with a public prefix, stop and ask me whether the value is safe to ship to the browser. Default answer is no.
- Supabase anon key: public is fine. Service role key: server-only, never imported in any file that runs in the browser.
DATABASE (Supabase / Postgres)
- Every new table gets Row-Level Security enabled in the same migration that creates it. Never create a table without RLS.
- Default deny: a table with RLS enabled and no policy denies all access. That is the safe state. Add explicit policies for what should be allowed.
- For "user can only see their own data" patterns, use auth.uid() = user_id (not auth.email()).
- SELECT, INSERT, UPDATE, DELETE each need their own policy. Don't write USING (true) on anything user-writeable.
- After writing a policy, write a quick test: pseudo-code that shows what an anon user, a logged-in user, and an admin can each see. Stop and confirm before applying.
API ROUTES
- Every API route checks authorization server-side, not just in the UI component.
- Privileged routes (admin, internal, user-data) check role server-side, before any data fetch.
- UI hiding (return null, return <NotAuthorized />) is not authorization.
DEPLOY
- Don't commit .env, .env.local, .env.production. If you create one, add it to .gitignore in the same change.
- Never hard-code an API key as a string literal, even temporarily.
When you're about to do something that contradicts these defaults, stop and ask me. Don't apologize and proceed.
This isn’t magic. Claude will still occasionally drift. But it cuts the rate at which the three patterns above ship by about 80% in our experience.
What this doesn’t fix
The system prompt above is a guardrail, not a guarantee. It changes how Claude writes new code. It doesn’t go back and audit the code Claude wrote yesterday, last week, or three iterations ago when you were still figuring out what the app was. That audit is the harder problem.
Two ways to do that audit:
- Read the code yourself. Slow but always works. Requires knowing what to look for, which is what the pillar guide is for.
- Have something check from outside. Patchable runs an outside scan on your deployed app — it doesn’t see your code, it sees what your app actually exposes. The Supabase keys in the bundle. The admin page responding without auth. The
.envleft in the deploy. Free to scan. You only pay if there’s something to fix.
Claude is honest about what it just shipped. Patchable tells you what’s actually shipped. The two are complementary. Run the scan.
Related reading
- The vibe coding security guide — the pillar piece this links into.
- Is vibe coding bad? — the honest answer.
- Supabase RLS for vibe coders — step-by-step on the second pattern above.
- Vibe coding with Cursor — different tool, different mistakes.