Vibe-coded apps ship faster than any prior generation of software. They also ship with vulnerabilities. Multiple 2024–2026 security audits found the majority of AI-generated code contains at least one critical vulnerability when shipped without a security pass. This is a 30-minute self-audit — five severity-ranked categories, five questions each, answered honestly. Share the result. Fix the gaps.
Most XSS, SQLi, and RCE vulnerabilities trace back to a missing validation or encoding step. Five checks.
textContent instead of innerHTML for dynamic content, or an HTML escaper for cases that need markup.Default to element.textContent = userInput. Reach for innerHTML only when you genuinely need to inject markup, and only after passing the input through an HTML escaper (or a vetted sanitizer like DOMPurify for rich text).
// vulnerable: any <script> in input runs el.innerHTML = userComment; // safe: text-only insertion el.textContent = userComment; // safe: rich text, sanitized el.innerHTML = DOMPurify.sanitize(userComment);
Pass user values as bound parameters; the database driver handles escaping. AI assistants frequently default to template-literal queries because they read more naturally — review every generated query for this pattern.
// vulnerable: SQLi via concatenation db.query(`SELECT * FROM users WHERE email = '${email}'`); // safe: parameterized db.query('SELECT * FROM users WHERE email = $1', [email]);
Cap size on the server. Read the first few bytes and verify against the declared MIME type. Reject anything that does not match. Do not trust the Content-Type header from the client — it is supplied by the uploader.
// detect by magic bytes, not extension
import { fileTypeFromBuffer } from 'file-type';
const detected = await fileTypeFromBuffer(buffer);
if (!ALLOWED_MIMES.has(detected?.mime)) reject();
if (buffer.length > MAX_BYTES) reject();Treat client-side validation as a UX nicety only. Every constraint enforced in the form must be re-asserted on the server. Use the same validation schema (Zod, Yup, Joi) on both sides if possible to keep them in sync.
// shared schema, enforced on the server
const SignupSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13).max(120)
});
app.post('/signup', (req, res) => {
const parsed = SignupSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error);
// ... use parsed.data
});One escaper does not fit all contexts. HTML-escaping a string that lands inside a JS string literal still allows breakouts via backslash. Shell-execute calls need execFile with array args, never exec with a built string.
// command injection exec(`grep ${pattern} file.txt`); // safe execFile('grep', [pattern, 'file.txt']);
Most account-takeover and data-leak incidents trace back to this layer. Five checks.
localStorage or sessionStorage where any XSS can steal them.Vibe-coded auth flows often use localStorage because the AI suggests it as the simplest option. The drawback: an XSS anywhere on the origin can read it. httpOnly cookies are JS-invisible, so the same XSS cannot exfiltrate the session.
// set on login response
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24 * 7
});Generate the token from a CSPRNG. Store its hash, not the raw value. Set an expiry. Mark consumed after use. AI assistants sometimes propose reset flows that email a base64-encoded user ID — a guessable identifier is not a token.
import crypto from 'node:crypto';
const token = crypto.randomBytes(32).toString('hex');
await db.passwordResets.create({
userId, tokenHash: sha256(token),
expiresAt: Date.now() + 30 * 60 * 1000,
consumedAt: null
});
sendEmail(user.email, `https://app/reset?t=${token}`);GET /orders/123 confirms order 123 belongs to the requesting user, not just that they are authenticated.Authentication answers "who are you"; authorization answers "what can you access". Vibe-coded APIs often check the first and skip the second. Add an explicit ownership predicate to every fetch.
// IDOR: any logged-in user can read any order app.get('/orders/:id', auth, async (req, res) => { res.json(await db.orders.findById(req.params.id)); }); // safe: scope to the owner app.get('/orders/:id', auth, async (req, res) => { const order = await db.orders.findOne({ id: req.params.id, userId: req.user.id }); if (!order) return res.status(404).end(); res.json(order); });
Hiding the admin button only stops accidental clicks. Anyone who opens DevTools or curls the API directly bypasses it. Wrap admin routes in a server-side role guard middleware.
function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: 'forbidden' });
}
next();
}
app.delete('/admin/users/:id', auth, requireAdmin, ...);Rate-limit by both IP and account identifier. Account-only is bypassable via IP rotation; IP-only fails when many users share a NAT. Lock to a small number of failures per minute, with exponential backoff or temporary lockout on repeated failures.
// per-IP: 10 / minute
// per-account: 5 / 15 minutes
const ipLimiter = rateLimit({ windowMs: 60_000, max: 10 });
const acctLimiter = rateLimit({
windowMs: 15 * 60_000, max: 5,
keyGenerator: req => req.body?.email
});
app.post('/login', ipLimiter, acctLimiter, ...);Five checks covering API field whitelisting, log sanitization, secret hardcoding, error leakage, and uploaded-file storage.
SELECT * shoveled to JSON, no internal flags leaking.Define a serializer per endpoint that whitelists fields explicitly. Vibe-coded APIs commonly return the full row including password_hash, internal_notes, or is_admin — use a "deny by default" projection.
// leaks password_hash, is_admin, ... res.json(await db.users.findById(id)); // safe: explicit shape function publicUser(u) { return { id: u.id, name: u.name, avatar: u.avatar }; } res.json(publicUser(await db.users.findById(id)));
Default request loggers capture full payloads — including the password field from POST /login. Configure a redactor that strips known-sensitive paths before they hit your log sink.
// pino redact example
const logger = pino({
redact: [
'req.body.password',
'req.body.token',
'req.headers.authorization',
'req.headers.cookie',
'*.creditCard'
]
});.env to version control — even briefly, even in a private repo.Add .env to .gitignore. Run gitleaks in pre-commit. If a secret has ever touched a public commit, rotate it immediately — assume scrapers have already grabbed it.
# .gitignore
.env
.env.local
.env.*.local
# add gitleaks as a git pre-commit hook
brew install gitleaks
echo 'gitleaks protect --staged -v' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitCatch all unhandled errors at the top level. Log the full stack server-side; return a generic message to the client. Ship a development-vs-production switch and verify production really hides the details.
app.use((err, req, res, next) => {
logger.error(err);
if (process.env.NODE_ENV === 'production') {
return res.status(500).json({ error: 'internal_error' });
}
res.status(500).json({ error: err.message, stack: err.stack });
});If uploads land in /public/uploads/ they are world-readable forever. Stash them in S3 / R2 / GCS, then mint short-lived signed URLs only after verifying the requesting user owns the file. Signing without an ownership check is IDOR (CWE-639).
// AWS SDK v3 (v2 is end-of-support since 2025-09-08).
// Authorize FIRST, then sign. Ownership check is non-optional.
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({});
// Scope the lookup by ownerId so a guessed/forged key cannot leak.
const file = await db.files.findOne({
key: req.params.key,
ownerId: req.user.id,
});
if (!file) return res.status(404).json({ error: "not_found" });
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket, Key: file.key }),
{ expiresIn: 300 }
);
res.json({ url });
// SDK v2: use s3.getSignedUrlPromise() (sync getSignedUrl has no await)Supply-chain attacks are rising sharply since AI-assisted development became mainstream. Five checks.
package.json / requirements.txt exists in the registry, has a non-zero install count, and is not a typo of a popular package (slopsquatting defense).AI assistants hallucinate package names — about 21.7% of OSS-model recommendations and 5.2% of commercial recommendations point at packages that do not exist. Attackers pre-register the most-hallucinated names. Eyeball every direct dep and verify weekly downloads on npm / PyPI before installing.
# quick sanity check
npm view express versions --json | tail -5
npm view express dependencies
# weekly downloads
curl -s https://api.npmjs.org/downloads/point/last-week/express \
| jq .downloadsnpm audit / pip audit / cargo audit shows zero CRITICAL vulnerabilities — patched, justified, or version-pinned with documented exception.Run the audit in CI. Fail the build on CRITICAL. For findings you decide not to patch, add a documented allow-list entry with a reason and an expiry date — never silent ignore.
# GitHub Action snippet
- run: npm audit --audit-level=critical
# or with allow-list
- run: npx audit-ci --critical \
--allowlist CVE-2024-12345Install with --production in the deploy step. Confirm bundles do not include dev-only utilities by inspecting the output. Vibe-coded apps frequently bundle the entire dev-dep tree because the AI scaffolded a single dependencies block instead of splitting into devDependencies.
npm ci --omit=dev
# or in Dockerfile
RUN npm ci --only=production
# verify bundle
npx webpack-bundle-analyzer dist/stats.jsonhttp:// fetches in browser code.Redirect HTTP → HTTPS at the edge. Set HSTS for at least one year. Audit fetch calls and image / script tags for any plain-http URL.
// Express HSTS via helmet
app.use(helmet.hsts({
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
}));Access-Control-Allow-Origin lists known origins, never * in production for credentialed endpoints.Wildcard CORS plus credentials is broken (browsers reject it), but wildcard CORS without credentials still leaks public endpoint data to any site. Allow-list specific origins for the production API.
app.use(cors({
origin: [
'https://app.example.com',
'https://staging.example.com'
],
credentials: true
}));Configuration drift is the slow-moving risk most teams underestimate. Five checks.
Use Helmet (Express) or your framework's equivalent. Test the result on securityheaders.com before launch.
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"] // prefer nonces/hashes over unsafe-inline
}
}
}));NODE_ENV=production, DEBUG=false, framework debug toolbars off.Verify on the deployed environment, not just in code. Many vibe-coded apps default to development mode because the AI never wrote a production switch — check /health or a debug-only route to confirm.
# inspect what production actually has set
heroku config -a my-app # or
flyctl secrets list -a my-app
# Django: confirm DEBUG=False
python -c "import django.conf; print(django.conf.settings.DEBUG)"admin/admin, postgres/postgres, or framework default left in place.Generate strong random passwords. Rotate any default the AI scaffolded into .env.example. Audit your Postgres / Redis / Mongo for permissive default users.
# generate
openssl rand -base64 32
# rotate Postgres
ALTER USER postgres WITH PASSWORD '...new...';If a secret entered git history, treat it as compromised — repo visibility changes, contributors come and go. Rotate at the source provider, then update the deployed environment.
# scan full git history for leaked secrets
gitleaks detect
# or scan all branches
gitleaks detect --log-opts="--all"
# rotate AWS access key
aws iam create-access-key --user-name svc-app
aws iam delete-access-key --access-key-id AKIA...CLAUDE.md / .cursorrules / system prompts before launch.The AI-specific failure mode: temporary instructions added during prototyping ("auth is hard, mock it for now") get persisted into the agent config and silently shape future code generations. Grep your AI configuration files for known anti-patterns before going live.
# grep AI config files for risky instructions
grep -r -E "(skip|ignore|disable|bypass).{0,30}(auth|security|validation|check)" \
CLAUDE.md AGENTS.md .cursorrules \
.github/copilot-instructions.md 2>/dev/null
# also check for eval / unsafe execution patterns
grep -r -E "(use eval|use exec|raw shell|no sandbox)" \
CLAUDE.md .cursorrules 2>/dev/nullOptional inline fix snippets for every item. Free checklist works without it; the unlock is the in-depth fix manual.
Every checklist item gains an inline "Show fix" panel with code-level remediation, vulnerable-vs-fixed snippets, and primary-source links (OWASP, CWE, vendor docs). One 5,000-sat confirmed payment unlocks all seven current priced features site-wide via the same TXID and shared client-side verifier. Verification happens in your browser against mempool.space's public REST API with blockstream.info as fallback — we never see your wallet, no account, no signup, no recurring charge.
Send at least 5,000 sats (≈ $5) to the address below from any BTC wallet. Wait for one on-chain confirmation (~10–30 min) before moving to Step 03 — unconfirmed transactions are rejected to prevent double-spend.
Your wallet shows the transaction ID after sending. It is 64 hexadecimal characters. Copy it.
Paste the TXID below. Your browser checks the public Bitcoin explorer; once it confirms the transaction is on-chain and pays the unlock address, the remediation panels open immediately and the same TXID unlocks all priced features site-wide.
bc1qs04leape97ner4wqa98n94l9n0gv9aa84eg4ux
No wallet integration, no Lightning, no bridge — plain on-chain BTC. Verification source: mempool.space REST API; blockstream.info fallback. Persistence: localStorage stores the local open/closed state — clearing site data re-locks the panel, but the same confirmed TXID can be re-pasted on any priced page. Need a custom audit instead?
Three things to know before you start.
Some checks are context-dependent — HTTPS enforcement on a localhost-only LAN tool, for example. The score is informational. The point is to make you think about each category, not grade you.
Every checked item is one bit; 25 bits pack into 7 hex chars as ?chk=<7hex>. Pasting a URL pre-checks the items the original auditor cleared. Invalid URLs reject; they do not silently mutate to blank.
This is a self-audit prompt — a way to systematically walk a vibe-coded app through common vulnerability classes before it ships. For consumer-facing or money-handling apps, hire a qualified penetration tester before launch.
?chk=<7hex>. URLs round-trip exactly; pasting a teammate's URL pre-checks the items they cleared. Invalid input (wrong length, non-hex, or non-zero padding bits) is rejected — corrupt URLs do not silently mutate to blank state.Found this useful? Tips in BTC always welcome — same address as the remediation unlock. No account, no signup, just paste.
bc1qs04leape97ner4wqa98n94l9n0gv9aa84eg4ux