How To Carefully Fix A Vibe-Coded Project
Try the interactive lab for this articleTake the quiz (6 questions · ~4 min)Sooner or later you get the message. A friend, a founder, a junior colleague in Amsterdam or Barcelona asks you to take a look at their project. They built it in a weekend with an LLM, pushed it to Vercel, showed it to a few users, and now it either has real traffic, real money, or real customer data flowing through it. When you open the repo, you find the usual pattern: a Next.js app with a Supabase project, a few API routes that were copy-pasted from Stack Overflow twelve layers deep, an .env.local that is also checked into git, a password column that contains SHA-256 hashes of plaintext passwords, and an admin panel protected by a hardcoded check for email === "[email protected]".
This is a vibe-coded project. It was built by feeling, not by reading documentation. It runs, it looks nice, and it is dangerous. The code was generated quickly, the defaults were accepted silently, and the parts that would have forced the author to slow down and think about security were glossed over because the preview kept working.
Fixing a vibe-coded project is not a question of "refactor it into something elegant." It is a security intervention performed on a running system. The wrong move breaks production, locks legitimate users out, or worse, leaks the exact data you were trying to protect. This article is a careful, step-by-step walkthrough of how to do that intervention without making the situation worse. It covers the classic issues (hardcoded keys, open databases, broken auth, missing validation, unsafe CORS) and the modern mechanisms that you should be using instead (Argon2id, WebAuthn, Row Level Security, Content Security Policy Level 3, short-lived tokens, secret managers, and strict input schemas).
The target audience is developers who know how to write code but have not spent years thinking about security, and who now have to clean up a codebase that was written without any security thinking at all. The goal is to finish reading this article with a clear, ordered plan and enough technical detail to execute it.
What "Vibe-Coded" Actually Means
The phrase comes from the idea that you write code based on the feeling of what should work, rather than on a deliberate understanding of what the system is doing. In practice, a vibe-coded project has a recognisable signature.
It usually has a single framework doing everything (often Next.js, SvelteKit, or Astro), a single BaaS doing everything else (Supabase, Firebase, Convex, Clerk, or similar), and almost no code between the client and those services. It has a dependency tree with six hundred transitive packages and a package.json full of ^ ranges. It has comments that read like chat transcripts. It has an /api/admin endpoint that checks nothing. It has a database where every table has select: true enabled for the anonymous key because the author got tired of fighting with Row Level Security. It has a Stripe webhook handler that accepts any POST request without verifying the signature. It has a password reset flow that sends the plaintext password by email because the framework scaffold suggested it and it worked in the demo.
None of these decisions were made with malice. They were made because the LLM suggested something, the app kept running, and the author moved on to the next feature. The pattern is: whatever the easiest path is, take it. Whatever the warning sign is, ignore it. Whatever the default is, keep it. After a few hundred of these small decisions you end up with a codebase that is one indexed query away from being on a public dataset.
The good news is that vibe-coded projects fail in very predictable ways. There is a small set of vulnerabilities that account for almost every incident, and a small set of fixes that cover almost every vulnerability. The rest of this article walks through them in the order you should actually apply them.
Step One: Triage Before You Touch Anything
The first rule of fixing a live system is to understand what you have before you change it. In a vibe-coded project this is especially important because the code is often the least reliable description of what is deployed. The actual behaviour lives in environment variables, dashboard settings, and whatever ad-hoc commands were run against the database last Tuesday.
Start by making a read-only inventory. Clone the repo, but do not run npm install with write access to anything important yet. Pull the environment variables from the hosting provider (Vercel, Netlify, Cloudflare Pages, Railway) and save them to a local file that is clearly marked as secret. Pull a dump of the database schema, not the data, with pg_dump --schema-only or the equivalent for your database. List the routes that the deployed app exposes. Check the DNS records, the certificate, the third-party services that are wired up (Stripe, PostHog, Resend, Sentry, whatever else is on the bill).
Now do a blast-radius assessment. Ask and answer, in writing, the following: What data does this project hold? Which users does it affect if the database is leaked? Which users does it affect if the app is taken offline for an hour while you fix it? How much money flows through it per day? Is there a legal category of data involved (health, financial, children's data, political opinions, biometric data) that pulls in the GDPR special categories in Article 9? Are there paying customers who would reasonably expect a working system while you refactor?
The reason this matters is that it changes the sequence of your fixes. If the app has a dozen test users, you can be aggressive. You can rotate keys, take the site down for fifteen minutes, break the login flow, and push forward. If the app has real paying customers and holds health data for a clinic in Vienna, you must stage every fix, test it, and schedule downtime with notice.
Write a short incident-style document for yourself: what the current state is, what the risk level is, what the target state is, and what the rollback plan is for each change. Keep it honest. If you do not have a rollback plan for a particular step, write "no rollback, must get right the first time" and treat it accordingly.
Finally, make a snapshot. Take a database backup, tag the current deployment, and record the current versions of the dependencies. If something goes wrong in the next hour, you want to be able to get back to this moment.
Step Two: Secrets And The Frontend
The single most common issue in a vibe-coded project is a secret that should be on the server ending up in the client bundle. In a Next.js App Router project this happens when a developer does not understand the difference between NEXT_PUBLIC_* variables and server-only variables. The NEXT_PUBLIC_ prefix makes a variable available in the browser. Everything else is only available on the server. The author often sees a variable not being available in a client component, adds NEXT_PUBLIC_, and the variable starts working. Nobody notices that the Stripe secret key, or the OpenAI key, or the Supabase service role key is now being shipped in every page view.
To find these, grep your code and your built output. The built output is the one that matters, because that is what gets sent to users.
# Look for env var usage in source
grep -rE "NEXT_PUBLIC_|VITE_|PUBLIC_" src/ app/ components/ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx'
# Build and then grep the built JS for anything suspicious
npm run build
grep -rE "sk_live|sk_test|service_role|SERVICE_ROLE|API_KEY|SECRET" .next/ out/ dist/ build/ 2>/dev/nullIf any real secret shows up in the built output, that secret is compromised and must be rotated. Not "should be", "must be". Browsers cache JS bundles, CDN edges cache them, users download them, curious attackers scrape them. Once a secret is in the bundle you have to assume it is public and rotate it at the source. Rotate the Stripe key in the Stripe dashboard. Rotate the Supabase service role key (and accept that every one of your backend services will need the new key). Rotate the OpenAI key. Rotate the database password. Rotate the SMTP password. Then, only then, change the code so the new secret is only used on the server.
The correct rule is: any secret that can modify state, charge money, send email, read private data, or authenticate a user belongs on the server. The client only ever sees the narrowest credential possible: a Stripe publishable key, a Supabase anon key, an OAuth client ID (not the secret), and nothing else.
Once the runtime secrets are safe, deal with the history. If the repository ever had a real secret committed, even once, it is still in the git history, and it is still findable by anyone who clones the repo. You cannot simply delete the line in a new commit. You need to either rewrite history with git filter-repo (the modern replacement for git filter-branch) or accept that the secret is compromised forever and rotate it. Most of the time, rotating is easier than rewriting history, and you should be rotating anyway because the secret has been exposed for as long as the repo has been public.
The third layer is the repo itself. Install a pre-commit hook that scans for secrets before anything is pushed. gitleaks is the standard tool for this, and you can wire it into pre-commit with a single configuration file. On the CI side, add a secret-scanning job that runs gitleaks detect or trufflehog on every pull request. GitHub also provides push protection for known-format secrets, which catches the simplest cases for free.
Step Three: Database Access And The Service Role Problem
Once the secrets are handled, the next question is who can actually read and write your database. In a vibe-coded project the answer is usually "everyone on the internet, via the anon key, because Row Level Security was turned off to make the demo work."
Row Level Security (RLS) is a Postgres feature that Supabase and every modern managed Postgres provider exposes. It lets you write SQL policies that run on every query and filter rows based on the current session context. If you disable it, any request to /rest/v1/users with the anon key returns the entire table. If you enable it without writing policies, the table returns nothing. The middle state ("enable and write correct policies") is where the security lives, and it is the state that almost every vibe-coded project skipped.
Audit this in two passes. First, list every table and check whether RLS is enabled. In Postgres:
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
ORDER BY schemaname, tablename;Second, for every table that has RLS enabled, list its policies:
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
ORDER BY schemaname, tablename, policyname;If a table holds user data and has no policies, or has a policy that reads USING (true), it is effectively public to anyone with the anon key.
Writing good policies is the part that trips people up. A common mistake is "a user can read their own row":
CREATE POLICY "users_self_read" ON users
FOR SELECT
USING (auth.uid() = id);This looks correct but quietly allows any authenticated user to enumerate row existence via 404 vs 403 timing, and it only works if the id column matches auth.uid(), which is a UUID from the JWT. If your users table uses integer IDs and a separate auth_user_id column, the policy must reference that column. Test every policy by logging in as two different users and confirming that the API correctly returns only their data.
The service role key is the other side of this coin. Supabase gives you a service role key that bypasses RLS entirely. It is meant to be used only on the server, for trusted operations like administrative jobs or webhooks. In vibe-coded projects the service role key is often used from the browser because the developer wanted a query to work and adding RLS policies was harder than just using the bypass key. Search for it:
grep -r "service_role\|SERVICE_ROLE_KEY" src/ app/ components/ --include='*.ts' --include='*.tsx'Every hit needs to be checked. The rule is: the service role key never touches a file that runs in the browser. Not in a useEffect, not in a component import, not in a route handler that is exported from a client file. If you find service-role access that genuinely needs to happen, move it to a server route, an edge function, or a separate backend process, and then rotate the key (because if you found it in the client, so did someone else).
For non-Supabase databases the same principle applies. If you are talking to Postgres directly, your application should use a database role with exactly the privileges it needs, not the default superuser. Create a role per service, grant only the specific schemas and tables, and use REVOKE to strip away the extra privileges that Postgres grants to PUBLIC by default. On managed services like Neon or Railway, create separate branches or separate databases for staging and production, and use different credentials for each.
One more thing that the author almost certainly missed: pooling. Serverless functions on Vercel or Cloudflare open new database connections on every invocation unless you use a pooler like PgBouncer or Supabase's own pooler. Without one, a short traffic spike exhausts your database's connection slots and the whole app goes down. Wire up a pooled connection string for your serverless runtime, and keep a direct connection for long-running jobs.
Step Four: Fix The Password Hashing
If the project stores passwords, the hash column is the single worst-case failure mode. A leak of bcrypt hashes is embarrassing. A leak of MD5 or SHA-256 hashes is a catastrophe. A leak of plaintext passwords (yes, this still happens) ends the project.
Check what is actually in the column. Log in to the database and look at a few rows.
SELECT substring(password_hash from 1 for 20) FROM users LIMIT 5;If you see $2b$12$... that is bcrypt with cost 12. Good enough for now. If you see $argon2id$v=19$m=..., that is Argon2id, the current recommendation. Good. If you see a 32-character hex string, that is MD5. If you see a 64-character hex string with no prefix, that is probably raw SHA-256. If you see the password itself, close the laptop, take a walk, and come back.
The fix for weak hashes is not to re-hash the existing column. You cannot turn an MD5 hash into an Argon2id hash without the plaintext. The fix is the "rehash on login" pattern. Add a new column (or a marker in the existing one) that records the algorithm. On every successful login, check whether the stored hash uses the current algorithm. If not, you already have the plaintext the user just typed, so hash it with the new algorithm and update the column.
import { verify as argon2Verify, hash as argon2Hash } from '@node-rs/argon2'
import bcrypt from 'bcryptjs'
import { createHash } from 'node:crypto'
async function verifyPassword(stored: string, plaintext: string) {
if (stored.startsWith('$argon2')) return argon2Verify(stored, plaintext)
if (stored.startsWith('$2')) return bcrypt.compare(plaintext, stored)
// Legacy unsalted SHA-256 (DANGER)
if (/^[0-9a-f]{64}$/i.test(stored)) {
const sha = createHash('sha256').update(plaintext).digest('hex')
return sha === stored
}
return false
}
async function loginAndMaybeUpgrade(user: { id: string; password_hash: string }, plaintext: string) {
const ok = await verifyPassword(user.password_hash, plaintext)
if (!ok) return false
if (!user.password_hash.startsWith('$argon2id')) {
const upgraded = await argon2Hash(plaintext, {
memoryCost: 64 * 1024, // 64 MiB
timeCost: 3,
parallelism: 1,
})
await db.updateUser(user.id, { password_hash: upgraded })
}
return true
}Argon2id parameters are a trade-off. The RFC 9106 recommended profile for interactive login is 64 MiB of memory, 3 iterations, and a degree of parallelism of 1. That takes roughly 40 to 80 ms on a modern server core, which is fast enough for login and slow enough that a GPU attacker needs real money and real time to make progress. On constrained environments (a Cloudflare Worker, a small Railway instance) you may need to drop to 32 MiB and 2 iterations. That is still fine. What is not fine is SHA-256, MD5, or any hash function that was not designed to be slow.
If you cannot change the library (for example, you are locked into a framework that only supports bcrypt), pick bcrypt with a cost of at least 12. Do not use scrypt with default parameters; its sanest profile requires careful tuning. Do not roll your own.
The pepper is the other refinement. A pepper is a secret value that gets mixed into the hash, stored only on the application server, not in the database. An attacker who dumps the database but does not compromise the server cannot reproduce the hashes without the pepper. Implement it as an HMAC applied to the plaintext before hashing.
import { createHmac } from 'node:crypto'
function peppered(plaintext: string) {
const pepper = process.env.PASSWORD_PEPPER!
return createHmac('sha256', pepper).update(plaintext).digest('base64')
}Pass peppered(plaintext) into Argon2id instead of the raw plaintext. Store the pepper in your server-side secret manager, rotate it separately from database credentials, and if you ever need to change it, do it with the rehash-on-login pattern from before.
Better still, consider whether you need passwords at all. WebAuthn and passkeys are supported by every major browser and by the platform authenticators (Apple Passwords, Google Password Manager, 1Password, Bitwarden, Windows Hello). For a new project there is no technical reason to store passwords, and the regulatory picture in the EU (NIS2, the upcoming Cyber Resilience Act) is moving in a direction that treats password-based auth as legacy. Offer passkey registration on first login and keep passwords only as a fallback for accounts that have not migrated yet.
Step Five: Sessions And Tokens
Most vibe-coded projects either use the default session handling their framework gives them or roll a JWT-based flow after reading one blog post about "stateless auth." Both paths have failure modes that you need to audit.
If the project uses a framework session (NextAuth, Clerk, Lucia, Auth.js), check three things. First, the session cookie must have HttpOnly, Secure, and SameSite=Lax (or Strict if your site has no cross-site login flows). If any of those is missing, an XSS bug becomes a full account takeover. Second, the session must be tied to a rotating secret. If the secret is a fixed value in .env that was generated with openssl rand -hex 16, an attacker who recovers it once can forge sessions forever. Store it in your secret manager, rotate it periodically, and accept that rotation invalidates all existing sessions. Third, the session must have an idle and an absolute expiry. A session that lives forever is a session that eventually leaks.
If the project uses JWTs directly, the list of things that can go wrong is longer. The classic alg: none vulnerability should not exist in a modern library, but custom JWT code is a special hazard. Check that your library refuses none and HS256 when the key is an RSA/EC public key, which was the 2015 JWT vulnerability that every library eventually patched. Check that tokens are signed with keys of the right length (at least 256 bits for HS256, at least 2048 bits for RS256). Check that you verify the issuer, audience, and expiry on every verification, not only the signature.
The deeper problem with JWTs in a vibe-coded project is revocation. A JWT is valid until it expires. If a user logs out, if you fire an employee, if you discover a compromised token, you cannot just "unpublish" a JWT. The solutions are all imperfect: short expiry plus refresh tokens (so the blast radius is a few minutes), a server-side revocation list (which gives up the "stateless" benefit JWTs were supposed to provide), or a per-user version number that you bump on logout (a sessions-lite approach). Pick one and implement it. The default of "just sign a JWT for thirty days" is a trap.
For APIs that serve web browsers, the modern answer is almost always server-set cookies, not JWTs carried in Authorization: Bearer headers. Cookies are automatically bound to the origin, they can be HttpOnly (so JavaScript cannot read them), and the browser handles them correctly across redirects. Bearer tokens in headers are the right choice for machine-to-machine APIs and mobile apps, not for first-party web traffic. If you find a browser-based login flow that stores a JWT in localStorage, move it to a cookie, set HttpOnly, and feel the tension release.
Step Six: Authorization, Not Just Authentication
Authentication proves who the user is. Authorization decides what they are allowed to do. Vibe-coded projects usually do the first and forget the second.
The most common vulnerability is the Insecure Direct Object Reference (IDOR). A user logs in, gets a valid session, and then calls /api/invoices/42. The endpoint looks up invoice 42 and returns it. It does not check whether invoice 42 belongs to the logged-in user. Any authenticated user can now read every invoice in the system by iterating IDs. This is by far the most common bug I have seen in LLM-generated API code, because the authentication middleware does its job and the developer assumes the rest is fine.
The fix is to always filter data by the owner, not only by the ID. In SQL:
SELECT * FROM invoices WHERE id = $1 AND user_id = $2;If that query returns zero rows, the response is 404, not 403, and definitely not a redirect to a login page that implies the invoice exists.
At the framework level, wrap every data access in a function that takes the acting user as an argument. Never write a function that looks like getInvoice(id). Write getInvoiceForUser(id, userId). Make it syntactically impossible to forget the user filter. If you are using an ORM, use a query builder pattern that requires the scope to be passed in. If you are using Postgres RLS, your policies act as a second layer of defence: even if the application forgets the filter, the database refuses to return rows that the policy rejects.
The second authorization problem is vertical privilege. A normal user should not be able to perform administrative actions. Vibe-coded projects often check this with a hardcoded email address or a role field in the session. Both are fragile. A hardcoded email breaks the first time you need a second admin. A role field that is set when the session is created does not reflect changes (if you demote a user, their session still says "admin" until it expires). The correct pattern is to check the current role from the database on every privileged request. If that is too slow, cache it with a short TTL (sixty seconds) and accept that privilege changes take a minute to propagate.
Finally, do not rely on client-side hiding. If the UI hides the "Delete Project" button for non-admins, the API endpoint must still check. Any competent attacker opens DevTools and unhides the button, or calls the API directly with curl. The UI is a hint, not a gate.
Step Seven: Input Validation And Output Encoding
The third column of the classic web security trio is input validation. A vibe-coded project usually has none. Every API endpoint accepts whatever the client sends, passes it to the database or to another service, and hopes the types are right. The result is a surface area where attackers can inject SQL, inject HTML, inject shell commands, inject prompts into an LLM call, or crash the server by sending a 100 MiB JSON body.
The modern pattern for validation is to define a schema per endpoint and parse the request against it. Libraries like zod, valibot, or arktype make this ergonomic in TypeScript. The parser produces either a validated, typed object or a structured error that you can return as a 400 response. Nothing in your handler runs until the input has been checked.
import { z } from 'zod'
const CreateInvoiceBody = z.object({
clientId: z.string().uuid(),
amountCents: z.number().int().positive().max(1_000_000_000),
currency: z.enum(['EUR', 'GBP', 'CHF']),
dueAt: z.string().datetime(),
lineItems: z.array(z.object({
description: z.string().min(1).max(500),
quantity: z.number().int().positive(),
unitPriceCents: z.number().int().nonnegative(),
})).min(1).max(100),
})
export async function POST(req: Request) {
const raw = await req.json().catch(() => null)
const parsed = CreateInvoiceBody.safeParse(raw)
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 })
}
const body = parsed.data
// body is now typed and safe to use
...
}This gives you three things for free. First, type safety from the network edge inward, so TypeScript actually reflects reality. Second, a rejection of any request that does not fit the schema, which catches most injection attempts that rely on sending unexpected types. Third, a natural place to bound sizes, which prevents resource-exhaustion attacks.
SQL injection specifically requires parameterised queries. Every modern database driver supports them. The failure mode is always the same: someone built a query by string concatenation because it was "simpler", and now name=';DROP TABLE users;-- is a possibility. Grep the codebase for template literals that contain both a variable and the word SELECT, INSERT, UPDATE, or DELETE, and rewrite them with placeholders.
// BAD
const users = await db.query(`SELECT * FROM users WHERE email = '${email}'`)
// GOOD
const users = await db.query('SELECT * FROM users WHERE email = $1', [email])ORMs (Prisma, Drizzle, Kysely, TypeORM) use parameterised queries by default, but they all offer a "raw" escape hatch. Audit every use of $queryRaw, sql.raw, .raw(), or similar. Each one is a place where the developer chose to bypass the safe path, and each one is a candidate for reviewing carefully.
Output encoding is the other half. Any user-generated content that ends up in HTML must be encoded so that <script> tags are displayed as text, not executed. React and Vue do this correctly by default for interpolated values, but break it whenever someone uses dangerouslySetInnerHTML or v-html. Search for those strings in the codebase and check whether the input is trusted. If it comes from a Markdown renderer, make sure the renderer sanitises HTML (use rehype-sanitize for unified/remark, use DOMPurify for anything dynamic in the browser). If it comes from the database, check how it got there: user-supplied HTML that was stored without sanitisation is a stored XSS waiting to happen.
The newest browser feature in this area is Trusted Types, which lets a Content Security Policy declare that innerHTML can only be assigned values of a specific sanitised type. Turning it on forces the application to route every HTML assignment through a sanitiser, which catches any XSS that made it past review. Chrome, Edge, and Opera support it in production today; Firefox and Safari are still catching up as of April 2026. Turn it on in report-only mode first, fix the violations, then enforce it.
Step Eight: CORS, CSP, And The Browser Security Model
Headers are boring and invisible, and that is exactly why they get ignored. A vibe-coded project either has no security headers or has the wrong ones, usually because the developer added Access-Control-Allow-Origin: * to make a fetch call work and then forgot.
CORS (Cross-Origin Resource Sharing) is the browser's mechanism for deciding whether a script on one origin can read responses from another origin. The default is no; CORS headers are how a server opts in. A vibe-coded project typically has one of these patterns.
The first is Access-Control-Allow-Origin: * plus Access-Control-Allow-Credentials: true. This combination is actually blocked by browsers, so it fails quickly. The author then often hardcodes a specific origin without understanding why. The correct pattern is to echo the request's Origin header back, but only if it appears in an allowlist.
const allowedOrigins = new Set([
'https://app.debtman.dev',
'https://staging.debtman.dev',
'http://localhost:3000',
])
function corsHeaders(req: Request) {
const origin = req.headers.get('origin') ?? ''
if (!allowedOrigins.has(origin)) return {}
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Vary': 'Origin',
}
}The Vary: Origin header is the detail that vibe-coded projects miss. Without it, CDNs cache the response for one origin and serve it to another, which either breaks legitimate cross-origin calls or silently lets unintended origins through.
Content Security Policy (CSP) is the other major header. It tells the browser which sources of script, style, image, font, and frame are allowed on the page. A project with no CSP is a project where any XSS bug becomes full account takeover because <script src="https://evil.example"> runs without complaint. A project with a permissive CSP (script-src 'unsafe-inline' 'unsafe-eval' *) is effectively no CSP at all.
The modern CSP, at Level 3, relies on nonces or hashes for inline scripts and strict dynamic loading for everything else. A sensible starting policy looks like this.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{random}' 'strict-dynamic';
style-src 'self' 'nonce-{random}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.debtman.dev;
frame-ancestors 'none';
base-uri 'none';
form-action 'self';
object-src 'none';
require-trusted-types-for 'script';The nonce-{random} value is a per-request random token that the server generates and puts on every <script> tag it intentionally emits. Any script the attacker injects via XSS does not have the nonce and is blocked. strict-dynamic lets scripts loaded by nonce-d scripts execute transitively, so modern bundlers (Next.js, Remix, SvelteKit) all work with it. Deploy the policy in Content-Security-Policy-Report-Only mode first, watch your telemetry for legitimate violations, fix them, and then switch to enforcing mode.
Alongside CSP, add the other headers that cost nothing and protect against common mistakes.
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-originHSTS with preload and two years of max-age is the target once you are confident TLS will keep working on every subdomain. Do not enable it lightly. Once you preload, you cannot back out for the duration of the policy.
Step Nine: Rate Limiting And Abuse Control
Vibe-coded projects usually have no rate limiting. This is fine until the first person tries to brute-force a login, or a crawler hits the search endpoint a thousand times per second, or someone discovers the LLM endpoint and starts running it as their own free ChatGPT.
Rate limiting must live at the edge. An API route handler that counts requests in-process does not survive a serverless cold start and does not coordinate across instances. Use either a shared store (Upstash Redis, Cloudflare KV, Durable Objects) or a platform feature (Cloudflare Rate Limiting Rules, Vercel's built-in rate limiting, AWS WAF).
The strategy depends on the endpoint. A login endpoint should use a sliding window per IP and per username, with aggressive limits (five attempts per minute per IP, fifteen per hour per username). Beyond that threshold you either lock the account (which invites denial-of-service abuse) or require a captcha (which works against bots but not against humans with Mechanical Turk crews). A safer middle path is to slow the response: after five failed attempts, hold the response for two seconds, then four, then eight. The legitimate user barely notices. The attacker's 30,000-attempt wordlist takes days instead of minutes.
For endpoints that call paid services (OpenAI, Anthropic, Stripe payment attempts, SMS verification), rate limiting is about cost control rather than security. A single motivated attacker can burn through thousands of euros of OpenAI credits in an afternoon. Put a per-user daily cap, a per-IP hourly cap, and a global daily cap. Instrument them and alert when any of them is approached. The cheapest version of this is a Cloudflare Worker in front of the endpoint that increments a Durable Object counter and rejects requests above the threshold.
The captcha debate is worth addressing because vibe-coded projects often reach for reCAPTCHA or hCaptcha on every form. Modern captchas hurt accessibility, leak data to the captcha provider (a real GDPR concern in the EU), and are routinely defeated by cheap solver services. Cloudflare's Turnstile is privacy-preserving, runs without user interaction for most real users, and is free, so if you need a captcha, use that one. Better still, use behavioural signals (typing cadence, mouse movement, time-to-submit) to distinguish humans from bots and only challenge the suspicious cases.
Step Ten: Dependencies, Supply Chain, And Transitive Trust
A modern web project inherits the security posture of its dependency tree. A vibe-coded project with 600 transitive packages inherits the posture of 600 different maintainers.
Start with an audit. npm audit, pnpm audit, and yarn audit all query public vulnerability databases and report known issues. They are noisy and full of false positives, but they are the baseline. Run the audit, triage the results, and fix the genuinely exploitable ones. For the rest, add a short note per advisory explaining why it does not apply to your codebase.
Beyond audit, set up automated dependency updates. Dependabot (GitHub) or Renovate (self-hosted or GitHub) both open pull requests for new versions. Configure them to batch updates, to run your test suite, and to auto-merge patch releases for trusted packages. The goal is that a critical vulnerability disclosed in a major library results in a PR landing in your repo within hours, not months.
Pin your lockfile. A vibe-coded project often has a package.json with ^ ranges and no committed lockfile, which means every deployment pulls slightly different versions. Commit package-lock.json, pnpm-lock.yaml, or yarn.lock, and use npm ci (not npm install) in CI. This makes the build deterministic and rules out an entire class of supply-chain attacks where a transitive dependency pushes a malicious release between your two deployments.
Consider a Software Bill of Materials (SBOM). Tools like syft or cdxgen generate a CycloneDX SBOM from your project, which lists every package and version you ship. Under the EU Cyber Resilience Act, which comes fully into force over 2027 for products with digital elements, providing an SBOM will be a legal requirement for a lot of European software. Generating one now, committing it with each release, and keeping it up to date costs nothing and gets you ahead of a compliance deadline.
Finally, consider where your packages come from. Most npm packages are fine. A small number are maintained by one person on a weekend. Be more suspicious of packages with few downloads, recent rapid version bumps, or changes in maintainer. Tools like npm-audit-resolver and socket.dev flag suspicious behaviour automatically. For the packages that really matter (cryptography, auth, payments) prefer well-known libraries with paid maintainers or foundation backing.
Step Eleven: Logging, Monitoring, And Knowing When Things Break
You cannot fix what you cannot see. A vibe-coded project usually has no logging strategy beyond console.log, no error reporting beyond whatever the hosting provider captures, and no alerting at all. This is survivable until something actually goes wrong, at which point you find out about the incident from a customer tweet.
The minimum viable logging setup for a web app has three layers. First, structured request logs on the server that record the path, method, status code, latency, user ID (if authenticated), and a request ID that propagates to all downstream calls. Second, error reporting that captures exceptions with stack traces and enough context to reproduce them: Sentry is the de facto standard and has a free tier. Third, metrics that record request rates, error rates, and latency percentiles (p50, p95, p99). Grafana Cloud, Datadog, or a self-hosted Prometheus stack all work.
Once you have data, set alerts. The rules of good alerts are simple: fire on symptoms, not causes, and only when a human needs to act. A 5xx rate above 1% for five minutes is an alert. A CPU at 80% is not (unless you know from experience that 80% CPU breaks something). A latency p99 above 5 seconds for the login endpoint is an alert. A single failed request is not.
The crucial piece that vibe-coded projects always miss is auditing sensitive operations. When someone logs in, log it. When someone changes their password, log it. When an admin deletes a user, log it. When a payment goes through, log it. Store those logs separately from normal application logs, with longer retention (one year is a reasonable default, longer for regulated data), and write-once if possible. The goal is that after an incident you can reconstruct exactly what happened, in what order, and by whom. Without this, every incident is a guessing game.
One concrete tip: put Sentry's user context on every request as soon as you know who the user is. If an error happens, you know which user saw it. If a user reports a bug, you can find their errors without grepping. If an attacker starts probing, you can see the pattern.
Step Twelve: GDPR And The Data You Did Not Realise You Collect
A project built in Europe, or serving European users, has to comply with the General Data Protection Regulation. Vibe-coded projects tend to ignore this until they get a complaint or a data access request, and then panic.
The first step is to know what personal data you actually hold. "Personal data" under GDPR is anything that identifies a person, directly or indirectly. Names, emails, IP addresses, device identifiers, even a sufficiently unique combination of browser fingerprint and timezone. If you have a PostHog or Plausible instance with IP addresses, you are processing personal data. If you have Sentry logging user IDs, you are processing personal data. If you have a logs table with user events, you are processing personal data.
For each category of personal data you hold, you need to know four things: why you hold it (the lawful basis under Article 6), how long you keep it, who you share it with, and how a user can exercise their rights (access, correction, deletion, portability). The vibe-coded project almost never has answers to any of this. The fix is to write a simple document that answers all four questions for every data category, and then make sure the code matches the document.
Concrete changes that usually need to happen in a vibe-coded project:
- Delete logs older than their retention period. If you keep Sentry events forever, that is a problem. Set a retention policy (thirty to ninety days is typical for error logs, one year for audit logs).
- Anonymise analytics where possible. Plausible and PostHog both offer EU-hosted, cookieless modes. Use them.
- Implement a "delete my account" endpoint that actually deletes the data, not just sets a flag. Delete rows from the primary tables, delete from the logs you can safely delete from, and record the deletion in the audit log.
- Implement a "export my data" endpoint that returns a JSON or ZIP of everything you hold about the user. Under Article 15 and 20, this is a legal right.
- Update the privacy policy to list every third-party service the project uses, what data goes there, and where that service is hosted. Vercel is in the US; Supabase defaults to AWS regions you chose, so check which one. If you are storing EU user data in a US region without a transfer mechanism, you are not compliant.
None of this is glamorous, and none of it is what an LLM will suggest when you ask it to build a new feature. But if you are serving real European users, it is not optional.
Step Thirteen: Deployment Hygiene And Secret Management
The last mile of a vibe-coded project is how it actually ships. The default pattern is an .env.local on the developer's machine, a copy of the same values pasted into Vercel's dashboard, and no other record anywhere. This works until the developer's laptop dies or a new person joins the team.
Move secrets out of .env files into a proper secret manager. For small projects, the hosting provider's built-in store (Vercel, Cloudflare, Railway) is fine. For anything more serious, use Doppler, Infisical, or a cloud-native solution (AWS Secrets Manager, Google Secret Manager, Azure Key Vault). These give you rotation, audit logs, per-environment access control, and a clean way to bring new team members online without emailing them a plaintext file.
Separate environments properly. A vibe-coded project often has a single Supabase project that serves as production, staging, and development all at once, because making more than one was "too much work". The consequence is that the first bad migration in development takes out production. Create a staging database that is a separate logical database, ideally with synthetic data, and wire it to a staging deployment with different secrets. Use feature flags (LaunchDarkly, Statsig, or a homegrown solution) to roll out changes gradually.
Automate your deployments. A vibe-coded project is often deployed by running vercel --prod from a laptop. This is fine for a prototype but catastrophic for a real product. Set up CI that runs the test suite, runs the linter, runs a security scan, and only deploys if all three pass. The CI job should use deployment credentials stored in the secret manager, not committed to the repo.
Back up the database on a schedule, and test that the backups actually restore. A backup you have never restored is a backup that does not exist. For Postgres, run pg_dump nightly into an object store (S3, R2, or similar), keep at least a week of daily backups and four weeks of weekly backups, and quarterly restore one into a scratch database to verify it works.
Step Fourteen: The Fix-And-Ship Loop
With the list of fixes in hand, the question becomes how to apply them without breaking the running system. The trap is to try to do everything at once, in a giant rewrite branch that takes three weeks and never lands. The correct approach is to apply one fix at a time, in priority order, and ship each one.
Priority order for a typical vibe-coded project:
- Rotate any secret that has leaked into the client or the git history. Same day.
- Enable RLS on every table that holds personal data, with policies. Same day or next day.
- Fix the hardcoded admin check. Same week.
- Fix password hashing if it is MD5, SHA-256, or plaintext. Same week.
- Add input validation to every endpoint that writes data. Within two weeks.
- Add rate limiting to login, signup, and any expensive endpoint. Within two weeks.
- Add security headers (CSP in report-only mode first). Within two weeks.
- Move session tokens to HttpOnly cookies if they are in localStorage. Within a month.
- Set up Dependabot, Sentry, and audit logging. Within a month.
- Write the privacy policy, data retention policy, and GDPR request endpoints. Within two months.
Each step should be a small pull request with a clear commit message, a test (or at least a manual verification plan), and a deploy to staging before production. Tag each deploy and keep the rollback SHA in the PR description. When one of the deploys breaks something, roll back first, investigate second.
The mindset that makes this work is patient pessimism. Assume every fix you make might break something. Assume the current state is worse than you think. Assume a user will do the weirdest possible thing with the app five minutes after you push. The vibe that built the project does not help you fix it. The thing that helps is the same thing that built every good piece of software: careful changes, small steps, reversible moves, and a genuine interest in the details.
By the time you are done, the project should not look radically different. The URLs are the same, the features are the same, the UX is the same. What changed is invisible. The secrets live in the right place. The database refuses the wrong questions. The hashes cost money to crack. The headers tell the browser what to trust. The logs tell you what happened. And the next time a friend asks you to take a look at their vibe-coded project, you have a checklist.
Final Notes On The Tools Mentioned
A quick reference list of everything referenced in this article, in case you want to pull something in today.
- Secret scanning: gitleaks, trufflehog, GitHub push protection
- Password hashing:
@node-rs/argon2(fast),argon2(pure),bcryptjs(fallback) - Input validation: zod, valibot, arktype
- ORMs with safe defaults: Prisma, Drizzle, Kysely
- HTML sanitisation: rehype-sanitize, DOMPurify
- Auth libraries: Lucia, Auth.js (NextAuth), Clerk, better-auth
- Passkeys:
@simplewebauthn/serverand@simplewebauthn/browser - Rate limiting: Upstash Ratelimit, Cloudflare Rate Limiting Rules, Vercel Rate Limit
- Dependency updates: Dependabot, Renovate
- SBOM generation: syft, cdxgen
- Error reporting: Sentry (free tier is generous), GlitchTip (self-hosted alternative)
- Secret managers: Doppler, Infisical, Vercel/Cloudflare built-ins
- Analytics (GDPR friendly): Plausible, Umami, PostHog EU
- Captcha (privacy friendly): Cloudflare Turnstile
- Security headers testing: securityheaders.com, observatory.mozilla.org
- CSP reporting: report-uri.com, or a self-hosted endpoint
None of these tools will save a project that refuses to be saved. All of them help a project that is being taken seriously. If you are the person who has been asked to clean up a vibe-coded codebase, take the help, make the list, and fix one thing at a time. It is slower than vibes. It is also the only way the project survives contact with real users.