Security audit + hardening
// verifiable artifacts
What happened
Platform analytics showed visitors hitting /admin, /api, and /login on vibekoded.com. The site has no such routes. They got 404s. The volume was low, well within the range of background reconnaissance bots that scan every site they encounter.
Most vibe coders see this and dismiss it. We didn't.
Two reasons. First: at this volume, background noise and a human probing the site for holes look identical. You can't tell from analytics alone. Better to assume mixed and harden. Second: hostile reconnaissance is unpaid pen-testing. Whoever's poking around is giving us intel on attack surfaces we should harden anyway. The same adversarial-readiness posture we apply to AI autonomy applies to security. Assume failure modes will be probed. Build to recover.
We dispatched a read-only audit.
The audit
Scope: both branches. The production main branch (currently deployed), and v2.6-foundation (the about-to-ship Question Box surface that introduces Postgres, a knowledge base, and email-delivery integrations).
Methodology: source-code review, dependency CVE scan, git history grep for leaked secrets, header configuration check, input sanitization audit, rate-limit completeness check.
Output format: severity-rated findings (critical / high / medium / low / informational), each with location, risk, and suggested remediation. No code changes during the audit itself. The audit is a read-only artifact. Operator reviews findings, ratifies priorities, then a separate task does the hardening.
The findings
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 1 |
| Medium | 3 |
| Low | 5 |
| Informational | 4 |
Zero critical findings on a Next.js site built by an operator-as-orchestrator practitioner is not the default outcome. Most vibe-coder sites have at least one critical finding on a first audit. The discipline shows.
What was already correct
Before getting to the three priorities, worth naming what the audit found CLEAN. These are the things that usually catch sites on fire, and they didn't catch this one:
- Zero secrets in git history. Every audit run against a repo with months of commits has at least one secret pasted into a file and committed. Not here.
- Fully parameterized SQL. The v2.6 Postgres integration uses parameterized queries everywhere. No SQL injection vector.
- No
evalor dynamic SQL. Nothing in the codebase that takes user input and converts it to executable code or queries. - Correct cookie flags. httpOnly, secure, sameSite all set appropriately.
- Strong header baseline. Content-Security-Policy, X-Frame-Options, Referrer-Policy all configured.
- No admin or debug routes. The probes that hit
/adminand/logingot 404s because those routes don't exist. Nothing to find. - Drafts excluded from sitemap.
- Honeypot done correctly. Bot-detection honeypot field on the intake form uses off-screen positioning, not
display:none. Thedisplay:noneapproach gets detected by sophisticated bots. Off-screen positioning doesn't.
The v2.6 routes are noticeably more careful about error disclosure than the older API routes. The team's security hygiene is visibly improving across versions. That's a methodology signal worth noting: the spec-driven build discipline produces better security defaults than the older patterns.
The three things we hardened
Three findings needed real attention. Two were pre-v2.6 blockers because v2.6 introduces new attack surfaces.
1. IP spoofing in the rate-limit function (HIGH).
The rate-limit function was using the leftmost value of the X-Forwarded-For HTTP header as the visitor's IP. That value is client-controlled. Anyone can send any IP they want. Which means anyone could bypass the rate-limit on the paid chatbot API endpoint by rotating fake IPs. Same for the Question Box submission throttle. Same source-of-truth poison would also have ended up in stored question records.
Plain English: the lock on the door was checking a name tag the visitor wrote themselves. Anyone who wanted in just wrote a different name on the tag.
The fix: switch to the x-real-ip header that the platform sets to the actual client IP, with a fallback to the rightmost X-Forwarded-For value (the rightmost is closest to our infrastructure, hardest to spoof). Standard pattern for proxied serverless platforms.
2. Unsalted IP hash was reversible (MEDIUM, pre-v2.6 blocker).
The Question Box was going to store a SHA-256 hash of each visitor's IP for rate-limit history and abuse pattern analysis. The privacy page said "we hash IPs so we don't have raw IPs." Sounds fine until you do the math.
SHA-256 is a one-way function but it's deterministic. Same input always produces the same output. IPv4 has only about 4 billion possible addresses. Anyone with a modern GPU can hash all 4 billion addresses in a few minutes and build a complete lookup table. Steal our database, run the table, recover every visitor's actual IP.
Translation: the privacy page was making a promise the math couldn't keep.
The fix: switch from plain SHA-256 to HMAC-SHA256 with a secret salt. The secret lives in environment variables, never in git, never in the database. Even if the database is breached, the hashes can't be reversed without ALSO stealing the secret. Two-breach requirement is dramatically harder than one. Privacy claim becomes honest.
This one is the methodology lesson worth keeping: don't make claims your math doesn't support. The SPEC said "hashed IPs persist for abuse analysis" without specifying the hashing approach. The privacy page said "we hash IPs." Both true in isolation. Together they implied a privacy guarantee the implementation didn't deliver. The fix wasn't just the code change; it was also amending the privacy SPEC's I-NO-IP-LOG invariant to specify HMAC-SHA256 with secret salt as the required implementation, and updating the privacy page to honestly describe what we do.
The discipline that catches this is invariant-precision. Vague invariants ("hashed IPs persist") hide implementation choices that matter. Precise invariants ("HMAC-SHA256-hashed with secret salt, 90-day retention") expose them. The SPEC discipline forced the precision.
3. In-memory rate-limit + cost cap on serverless (MEDIUM).
The chatbot endpoint had a $50/day cost cap in code. Sounds reasonable. But the platform runs serverless functions across an unpredictable number of instances, each with its own in-memory state, each resetting on cold start. The actual daily LLM spend ceiling was therefore "$50 times however many instances were live that day, and reset whenever instances cycled." Could be $50, could be much more.
The fix here is partial because the financial exposure is already capped by an account-level daily spend limit set externally. Even if the in-memory cap fails completely, the account stops accepting requests once daily allowance is exhausted. The remaining concern is service availability: an attacker could burn through the day's allowance in an hour and block legitimate users until tomorrow. That's a real problem, but it's a service-disruption problem, not a financial-exposure problem.
We deprioritized the full fix (shared state via a KV store) to a post-v2.6 hardening pass. The defense-in-depth is enough for now. When traffic grows enough that the disruption window becomes painful, we'll move to shared state.
This is gated-autonomy applied to security debt: not every finding gets fixed immediately. The ones that compromise active promises (rate-limit + privacy claim) ship now. The ones that are real but already mitigated by other layers (cost cap + account-level limit) wait until they bite. Spec the threat. Tier the remediation. Commit the priorities.
What this proves about the methodology
Two things, and they generalize past security.
Specs find honesty problems before users do. The SPEC discipline forced us to write down what we were promising visitors. The privacy SPEC's I-NO-IP-LOG invariant said "no raw IPs persisted." The Question Box SPEC's §4.1 said "ip_hash TEXT NOT NULL." The contradiction between those two declarations was the bug. Writing the SPECs surfaced it. If we'd shipped without the audit, the privacy page would have been technically incorrect. Operators who trust the page would have been wrong to. SPECs as a forcing function for honesty is the load-bearing claim.
Adversarial readiness isn't paranoia, it's calibration. The reconnaissance traffic was probably background bots. The audit response treated it as if it were targeted. The cost of the audit was a few hours; the cost of being wrong about the noise level would have been a real exploit. The asymmetry favors audit-on-uncertainty. Apply the same posture to AI autonomy, to deployment risk, to anything where the cost of "we should have caught this" is high.
Status
Pre-v2.6 blockers: closed. Service-availability hardening: scheduled for post-v2.6. Application-level discipline carries security in the meantime.
Next probe traffic that shows up gets the same treatment.