SKILL.md
Vibe-Proof: Security Hardening for Full-Stack Apps
Purpose: Systematically audit and fix security vulnerabilities in vibe-coded full-stack applications through parallel multi-agent analysis and guided remediation.
Origin
Extracted from a real hardening session on a React + Express + Stripe e-commerce platform deployed to Vercel. Refined through a second execution on a Next.js 16 + Supabase + CRM medical platform (Feb 2026). Between both sessions: 85+ issues found, including SQL injection, hardcoded backdoor passwords, secrets in URL params, .env files in git, and missing security headers.
When to Use
- After vibe-coding an MVP with API routes, databases, or payment integrations
- Before first real deployment or first real customer
- When you suspect "it works but is it safe?"
- Any Express/React/Next.js/Nuxt app with a backend
The Seven Security Checks
1. Injection Vectors
- No user input in SQL/query strings without parameterization
- Sort columns, filter fields use allowlist validation
- No
eval(),new Function(), or template literal injection - URL params parsed with bounds checking (parseInt with min/max)
- Enum fields (gender, status, role) validated against const allowlists
2. PII & Secret Exposure
- No hardcoded addresses, phone numbers, names in source
- No hardcoded passwords or "backdoor" auth strings
- API tokens in headers (Authorization), never in URL params
- Admin endpoint secrets use
Authorization: Bearerheader, not query params - No
.envfiles tracked in git (checkgit ls-files | grep env) - No secrets in client-side code or VITE/NEXTPUBLIC_ vars that shouldn't be public
-
.env.exampledocuments all required variables (including sync secrets, CRM keys) - No
localhostURLs in production allowlists (ALLOWED_ORIGINS, CSP, etc.)
3. Missing Security Headers
-
X-Content-Type-Options: nosniff -
X-Frame-Options: DENY(or SAMEORIGIN if iframes needed) -
X-XSS-Protection: 0(modern best practice -- disables buggy browser filter) -
Referrer-Policy: strict-origin-when-cross-origin -
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload -
X-DNS-Prefetch-Control: off(privacy -- prevents browser DNS leaks) - Body size limits on
express.json()andexpress.urlencoded()(Express) - CSP
img-srcrestricted to specific CDN domains (nothttps:wildcard) - CSP
script-srcwithoutunsafe-eval(remove if WebGL/shaders were deleted)
4. Error Leakage
- Production error responses don't expose stack traces
- 500 errors return generic message, not
error.message - No
console.logof sensitive data (tokens, passwords, PII) - Structured logger used instead of console.* in production code
- Catch blocks return masked errors:
"Internal server error"noterr.message
5. Input Validation Gaps
- All POST/PUT endpoints validate body with Zod or equivalent
- Query params have type coercion and bounds (limit, offset, id)
- Integer params checked against MAX_INT (2147483647)
- Enum params validated against
const ALLOWED_X = [...] as constallowlists - File uploads check size AND validate magic bytes (not just MIME header)
- File extensions derived from validated MIME type, not user-supplied filename
- Token/secret params validated for format (min length, charset) before DB lookup
- Text inputs sanitized (strip HTML tags, dangerous chars) before storage
6. Dead Code & Attack Surface
- Unused routes/endpoints removed
- Unused components deleted (not commented out)
- Disabled features removed entirely (not just
if(false)) - Test/debug endpoints not in production
- Unused npm packages removed
- No GET handler aliasing POST on write endpoints (
export { POST as GET }) - No conflicting static + dynamic files (e.g.,
robots.txt+robots.ts) - Unused client utility functions removed (dead
createBrowserClient, etc.) - YouTube/video embeds use privacy-enhanced mode (
youtube-nocookie.com)
7. Credential Hygiene
- Session secrets are 32+ characters
- Cookies:
httpOnly,secure(production),sameSite: 'lax' - Trust proxy configured when behind reverse proxy (Vercel, nginx)
- Webhook endpoints verify signatures (Stripe, etc.)
- Rate limiting on auth, checkout, newsletter, AND admin/sync endpoints
- Rate limiting strategy appropriate for platform (in-memory is defense-in-depth on serverless; use Upstash/KV for persistent limiting)
Execution Process
Phase 1: Parallel Audit (Read-Only)
Launch 3 specialized agents in parallel to scan different layers simultaneously:
Agent 1: Frontend Audit
Audit the frontend code for security issues:
- XSS vectors (dangerouslySetInnerHTML, unescaped user input)
- Sensitive data in client-side code
- Tracking pixels with undefined variables
- Console.log statements leaking data
- Dead/unused components
- API keys or tokens in VITE_*/NEXT_PUBLIC_* env vars that shouldn't be public
- YouTube/video embeds not using privacy-enhanced mode
- sessionStorage/localStorage holding PII unnecessarily
Report each issue with file:line, severity, and fix suggestion.
Agent 2: Backend/API Audit
Audit the backend code for security issues:
- SQL injection (user input in query strings, unvalidated sort/filter)
- Missing input validation on POST/PUT endpoints
- Hardcoded PII (addresses, phone numbers, names)
- Hardcoded passwords or backdoor auth strings
- API tokens or secrets in URL params instead of Authorization header
- GET handlers that alias POST on write endpoints
- Console.log/error statements (should use structured logger)
- Error handlers leaking internal details (returning err.message to client)
- Missing rate limiting on sensitive endpoints (including admin/sync)
- Missing enum/allowlist validation on fields like gender, status, role
- File extension derived from user filename instead of validated MIME type
- Duplicate utility code that should be extracted to shared modules
Report each issue with file:line, severity, and fix suggestion.
Agent 3: Config & Credential Audit
Audit configuration and credentials:
- .env files tracked in git (git ls-files | grep env)
- .env.example missing required variables
- Security headers present/missing (check next.config headers or Express middleware)
- X-XSS-Protection should be "0" (not "1; mode=block")
- HSTS header with adequate max-age (63072000+) and preload
- CSP img-src using wildcard https: instead of specific domains
- CSP script-src with unnecessary unsafe-eval
- localhost URLs in production allowlists (ALLOWED_ORIGINS, CSP connect-src)
- Body size limits configured (Express)
- Session configuration (secret length, cookie flags)
- Trust proxy setting
- Conflicting static + dynamic files (robots.txt vs robots.ts)
- Dead code files (unused components, disabled features)
- Unused npm dependencies (especially heavy ones like shader libs)
Report each issue with file:line, severity, and fix suggestion.
Phase 2: Synthesize & Prioritize
Combine all findings into a single prioritized list:
| Priority | Category | Fix Order |
|---|---|---|
| CRITICAL | Backdoor passwords, injection, credential leaks, secrets in URLs | Fix first |
| HIGH | PII exposure, missing validation, error leakage, missing HSTS, GET-as-POST | Fix second |
| MEDIUM | Missing rate limits, enum validation, dead code, CSP tightening | Fix third |
| LOW | Unused packages, console.log, config optimization | Fix last |
Deduplication: Agents will find overlapping issues. Merge duplicates and keep the most detailed description.
Phase 3: Systematic Fix Execution
Fix in priority order. After each fix category:
- Run
npm run build(or project equivalent) - Verify no regressions
Common Fix Patterns:
Backdoor Password Removal
// BEFORE: Hardcoded backdoor (CRITICAL)
const password = searchParams.get("password");
if (password !== "myapp2024") return unauthorized();
// AFTER: Environment variable via Authorization header
const authHeader = request.headers.get("authorization");
const secret = authHeader?.replace(/^Bearer\s+/i, "");
if (!process.env.SYNC_SECRET || !secret || secret !== process.env.SYNC_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
Enum Allowlist Validation
// Define allowlists as const arrays
const ALLOWED_GENDERS = ["male", "female", "other"] as const;
const ALLOWED_LANGUAGES = ["es", "en"] as const;
// Validate before using
if (!ALLOWED_GENDERS.includes(data.gender)) {
return NextResponse.json({ error: "Invalid gender value" }, { status: 400 });
}
MIME-Based File Extension
// BEFORE: Trust user filename (attackable)
const ext = file.name.split('.').pop();
// AFTER: Derive from validated MIME type
const MIME_TO_EXT: Record<string, string> = {
"application/pdf": "pdf",
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
};
const ext = MIME_TO_EXT[file.type] || "bin";
DRY: Extract Shared Service Clients
// BEFORE: createClient() duplicated in 3+ route files
// AFTER: Shared module (src/lib/service-client.ts)
import { createClient, SupabaseClient } from "@supabase/supabase-js";
export function createServiceClient(): SupabaseClient | null {
const url = process.env.SERVICE_SUPABASE_URL;
const key = process.env.SERVICE_SUPABASE_KEY;
if (!url || !key) return null;
return createClient(url, key);
}
SQL Injection (Whitelist Pattern)
const ALLOWED_SORT_COLUMNS: Record<string, string> = {
'created': 'created_at',
'rating': 'average_rating',
'name': 'name',
'price': 'price::numeric',
};
if (filters?.sortBy && ALLOWED_SORT_COLUMNS[filters.sortBy]) {
const sortColumn = ALLOWED_SORT_COLUMNS[filters.sortBy];
query += ` ORDER BY ${sortColumn}`;
}
API Token in Header (Not URL)
// BEFORE: Token in URL (visible in logs, browser history)
const url = `${API_URL}?access_token=${TOKEN}`;
// AFTER: Token in Authorization header
fetch(API_URL, {
headers: { 'Authorization': `Bearer ${TOKEN}` }
});
Security Headers -- Next.js (next.config.ts)
// In next.config.ts headers() array:
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-XSS-Protection", value: "0" }, // Modern: disable buggy filter
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-DNS-Prefetch-Control", value: "off" },
Security Headers -- Express (middleware)
app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '0');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
next();
});
Error Response Masking
// Express
app.use((err, _req, res, _next) => {
const status = err.status || 500;
res.status(status).json({
error: status >= 500 ? 'Internal Server Error' : err.message
});
});
// Next.js route handler
} catch (err) {
console.error("Route error:", err); // Log internally
return NextResponse.json(
{ error: "Internal server error" }, // Mask externally
{ status: 500 }
);
}
Remove GET-as-POST Alias
// BEFORE: Exposes write endpoint to GET requests (CSRF, caching, logging)
export async function GET(request: NextRequest) {
return POST(request);
}
// AFTER: Delete the GET export entirely. Only export POST.
Phase 4: Credential Remediation
If .env files were tracked in git:
# Add to .gitignore
echo ".env.production" >> .gitignore
# Remove from tracking (keeps local file)
git rm --cached .env.production
# Alert: Rotate ALL exposed credentials
Credentials that MUST be rotated if exposed:
- Database passwords / Supabase service role keys
- API keys (Stripe, Shippo, Resend, etc.)
- Session/sync secrets
- Webhook signing secrets
Phase 5: Environment Variable Provisioning
If deploying to Vercel, set env vars via API:
# Get project ID
curl -s -H "Authorization: Bearer $VERCEL_TOKEN" \
"https://api.vercel.com/v9/projects?teamId=$TEAM_ID" | \
jq '.projects[] | select(.name == "PROJECT_NAME") | .id'
# Set env var (note: will return ENV_CONFLICT if already exists)
curl -s -X POST \
"https://api.vercel.com/v10/projects/$PROJECT_ID/env?teamId=$TEAM_ID" \
-H "Authorization: Bearer $VERCEL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"key": "VAR_NAME", "value": "VAR_VALUE", "type": "encrypted", "target": ["production", "preview"]}'
Phase 6: Verify & Deploy
# Build verification
npm run build
# Commit (conventional commit format)
git commit -m "fix(security): harden platform -- [summary of fixes]"
# Push and deploy
git push origin main
Phase 7: Post-Deploy Connection Verification
After deploy, verify all external services are reachable from production:
- Test each service endpoint with a minimal query (Supabase:
select count, CRM API:GET /contacts?limit=1) - Check for common failures:
- ENOTFOUND / NXDOMAIN -- Supabase free-tier project paused (unpause in dashboard) - 401 Unauthorized -- API key mismatch between .env.local and Vercel env vars - schema cache error -- Supabase just restored, wait 1-2 min
- Clean up any test records created during verification (check all synced downstream services)
Success Criteria
- Build passes with zero warnings
- No user input reaches SQL without parameterization or allowlist
- No PII or hardcoded passwords in source code
- No API tokens or secrets in URLs
- No .env files tracked in git
- Security headers present on all responses (HSTS, DENY, nosniff)
- Error responses don't leak internals
- All POST/PUT endpoints validate input (including enum allowlists)
- File extensions derived from MIME type, not filename
- Dead code deleted (not commented)
- No GET aliases for POST endpoints
- Exposed credentials rotated
- All external service connections verified post-deploy
Lessons Learned
- Parallel audit agents save massive time. 3 agents scanning different layers simultaneously catches issues that sequential review misses.
- Sort columns are the #1 SQL injection vector in vibe-coded apps. Everyone parameterizes WHERE clauses but forgets ORDER BY.
.envfiles in git are shockingly common. Always checkgit ls-files | grep -i envas the very first step.
- Hardcoded "temporary" passwords become permanent backdoors. Search for string comparisons against literals in auth logic:
password !== "something",secret === "hardcoded".
- GET aliasing POST is a silent CSRF vector.
export { POST as GET }orGET(req) { return POST(req) }exposes write endpoints to CSRF, browser prefetch, and CDN caching.
X-XSS-Protection: 1; mode=blockis outdated. Modern recommendation is0-- the browser filter itself has been exploited. CSP is the real protection.
- Enum validation is easy to forget. Gender, status, role, language -- any field with a finite set of values needs a const allowlist, not just type checking.
- File extension from filename is attackable. A file named
malware.pdf.exewithapplication/pdfMIME should get.pdfextension from MIME, not.exefrom the name.
- Duplicate service client code = duplicate security gaps. Extract shared modules so auth logic is fixed in one place.
- Vercel API for env vars is faster than the dashboard. One curl loop sets 10 variables in seconds vs. clicking through the UI.
- Supabase free tier pauses after inactivity. DNS record disappears (NXDOMAIN). After unpause, schema cache takes 1-2 min to warm. Production may work before local due to separate connection pools.
- Always test connections after deploy. A passing build doesn't prove services are reachable. Test each external service with a minimal query post-deploy.
localhostin production origin allowlists is a real finding. Easy to leavehttp://localhost:3000in ALLOWED_ORIGINS during development.
Part of Claude Code Skills -- reusable skills extracted from real AI-assisted development sessions.