Finding vulnerabilities is easy and getting cheaper every year. Fixing them requires a mechanism, and that's the part almost everyone skips. A review that doesn't become tracked work produces a document, not a safer product.
The concept
Security for a small product isn't about exotic attacks. It's about a short list of well-understood failures that appear in nearly every codebase, plus one process problem that determines whether any of them get fixed.
The realistic threat model
You are not being targeted by a nation state. You are exposed to:
| Threat | Reality |
|---|---|
| Automated scanners | Constant, indiscriminate, hitting every public endpoint |
| Curious users | Will open DevTools, edit local storage, and try ?admin=true |
| Credential stuffing | Leaked passwords from other breaches, tried against you |
| Abuse of free tiers | Scripts exhausting your quotas or generating your costs |
| Accidental exposure | Secrets in git, public buckets, over-permissive rules |
| Supply chain | A dependency ships something you didn't audit |
The last two cause more real incidents at small scale than clever attacks do.
The short list that matters
1. Never trust the client. Anything a client can send, an attacker can forge. Entitlement, permissions, prices, user ids in request bodies, all must be verified server-side. If a URL parameter or a local storage value can grant access, it will.
2. Authorization on every request. Authentication says who; authorization says what they may touch. The classic failure is checking that someone is logged in and not checking whether this record is theirs.
3. Escape all output. Any user-controlled data rendered into HTML must be escaped. Frameworks do this by default; going without one means re-adding it deliberately (Chapter 15).
4. Parameterise all queries. Never build SQL by string concatenation. Every ORM and query builder does this correctly; hand-rolled string building does not.
5. Secrets never reach a client. Publishable keys are designed to be public provided your server rules are correct. Secret keys never touch a browser or a binary.
6. Rate-limit every public endpoint. Signup, login, password reset, contact forms, anything that sends email or costs money.
7. Validate input server-side. Client validation is UX. Server validation is security.
8. Never become an open relay. Any endpoint that sends email, SMS, or requests to arbitrary addresses on user input is an abuse vector, and it spends your reputation.
9. Fail closed. Missing configuration must deny, never allow.
10. Audit your dependencies. Know what ships in your binary and what it collects.
⭐ The process problem
Here's the part that actually determines outcomes.
A review, automated, AI-assisted, or human, produces a list of findings. Then, reliably, this happens:
- Findings live in a document or a chat message
- They have no owner, no date, no tracking
- Feature work has deadlines; findings don't
- Nothing enforces them, so nothing fails
- Months later they're still open
Work without a deadline loses to work with one, every single time. The fix is mechanical, not motivational:
1. CONVERT each finding into a tracked item — issue, ticket,
or a TODO(sec) in the code. Somewhere it will be SEEN AGAIN.
2. ASSIGN severity and a DEADLINE
Critical → before next release
High → before launch
Medium → dated backlog
Low → backlog
3. ADD A RELEASE GATE
"All Critical and High closed" — a checkbox you must tick
4. AUTOMATE what can be automated
secret scanning · dependency audit · a grep for the specific
pattern that caused the finding
5. RE-RUN THE REVIEW before launch
Once at the start is diagnosis. Once before shipping is the gate.
Step 1 is the whole thing. A finding not in a tracked list does not exist.
Code review that finds real problems
Whether by a person or a tool, the useful questions are:
- What happens with malicious input here?
- What happens when this fails?
- Can a user access another user's data through this path?
- Is anything granted based on client-supplied data?
- Is there a fallback that changes behaviour silently? (Chapter 29)
- What are the edge cases, empty, maximum, concurrent, offline?
AI review is genuinely good at this now, it reads more thoroughly than a tired human and finds real exploitable issues. It's poor at judging which matter for your context, and it has no mechanism to ensure anything is fixed.
Security by capability
Each capability you add brings obligations:
| You add | You now need |
|---|---|
| Accounts | Rate-limited auth, secure sessions, account deletion |
| Payments | Server-verified entitlement, webhook signature checks |
| File uploads | Type/size validation, no execution, scanned or isolated storage |
| User content | Escaping, moderation, reporting |
| Email sending | Rate limits, no open relay, authentication (Chapter 35) |
| Third-party data | Treat as untrusted; never execute |
| AI features | Prompt injection defence; never authorise from model output |
📐 Best practice
Run a security review early and again before launch.
⭐ Convert every finding into a tracked item with a severity and a date, in the same session.
Add "all Critical/High closed" as a release gate.
Never grant access from client-controllable input.
Escape all output; parameterise all queries.
Rate-limit every public endpoint.
Secret-scan in CI. Rotate anything that has ever been committed.
Audit dependencies with npm audit / Dependabot, and know what each SDK ships.
Fail closed everywhere.
Run your platform's security advisor, managed databases ship one.
Sweep backward when you fix a class of issue.
💀 Common mistakes
⭐ Findings that never become tracked work. The dominant failure.
Client-side entitlement. URL parameters, local storage flags, hidden fields.
Authentication without authorization. Logged in ≠ allowed to see this record.
Unescaped output. Especially without a framework.
String-concatenated SQL.
Secrets in the repo. Git history is forever, rotate, don't just delete.
No rate limiting on signup, login, reset, or anything that sends mail.
Open relays. An endpoint that emails arbitrary submitted addresses.
Fail-open configuration. Missing secret treated as "no auth needed."
Over-permissive database rules. "Allow all authenticated" is not authorization.
Public storage buckets.
Ignoring "only exploitable if…" findings. Conditions change; that's a finding about your roadmap.
Fixing forward only. New code safe, old code still exposed.
The professional workflow
1. THREAT MODEL — 15 minutes. What's valuable? Who'd want it? Where's it exposed?
2. AUTOMATED BASELINE
dependency audit · secret scan · static analysis ·
platform security advisor
3. MANUAL/AI REVIEW of auth, payments, data access, public endpoints
4. ⭐ CONVERT FINDINGS → tracked items, severity, DATE — same session
5. FIX CRITICALS NOW
6. AUTOMATE THE REGRESSION
a CI check for the exact pattern that caused each finding
7. ADD THE RELEASE GATE
8. RE-RUN before launch
9. SWEEP BACKWARD for the same shape elsewhere
10. RECURRING: monthly dependency audit; annual full review
Tools, websites & costs
| Need | Tool | Cost |
|---|---|---|
| AI code/security review | Claude, CodeRabbit, Greptile | $0-30/mo |
| Dependency audit | npm audit, Dependabot, Snyk | Free |
| Secret scanning | gitleaks, TruffleHog, GitHub secret scanning | Free |
| Static analysis | Semgrep, CodeQL | Free (OSS) |
| Database advisors | Supabase/Firebase built-in advisors | Free |
| Bot / abuse protection | Cloudflare Turnstile, hCaptcha | Free |
| Rate limiting | Upstash Ratelimit, platform middleware | Free tiers |
| Header / TLS check | securityheaders.com, SSL Labs | Free |
| Vulnerability disclosure | A security.txt and a monitored email | Free |
| Pen test | An agency | $5k-50k, not pre-launch |
| Compliance (SOC 2) | Vanta, Drata | $5k+/yr, when enterprise asks |
Everything a pre-launch product needs is free. The bottleneck is never cost; it's follow-through.
Alternatives & trade-offs
AI review vs human vs automated scanning. Automated scanning catches known patterns cheaply. AI review reads context and finds logic flaws. Humans bring judgement about what matters. Use all three; they find different things.
Fix everything vs triage. Fixing every low-severity finding burns time you need for the product. Ignoring criticals is negligence. Triage by exploitability × impact, and be honest that "hard to exploit today" often means "easy after your next feature."
Security early vs later. Retrofitting authorization into a product built without it is a rewrite. Retrofitting rate limiting is an afternoon. Get the architectural ones right early, authorization model, secret handling, entitlement, and defer the operational ones.
Bug bounty. Effective and premature before you have meaningful traffic. A security.txt with a monitored email costs nothing and catches good-faith reports.
Compliance certifications. SOC 2 is a sales unlock, not a security improvement. Do it when a deal requires it.
Checklist
- A security review has been run
- ⭐ Every finding is a tracked item with severity and a date
- "All Critical/High closed" is a release gate
- The review will be re-run before launch
- No access granted from client-controllable input
- Authorization checked on every request, not just authentication
- All output escaped; all queries parameterised
- No secrets in the client or in git history
- Rate limiting on every public endpoint
- No open relay, nothing sends to arbitrary submitted addresses unconfirmed
- Everything fails closed
- Database rules verified with an actual cross-user test
- Dependencies audited; binary contents known
- Platform security advisor run
- Regressions automated in CI
📓 Case Study: eight findings on day one, seven still open on day twenty-four
Project: SOLIS. A serious automated security review ran in the first hours of the project and produced eight findings, each with a concrete exploit scenario. Excellent work, done early, exactly as recommended.
Selected findings, verbatim:
Premium access is granted by checking a client-side URL parameter and storing the result locally, no server verification. Any user appends
?unlocked=1; the code sets premium and saves it. Full paywall bypass, permanently, with no server gate to fall back on.The waitlist endpoint has no origin check, rate limit, or CAPTCHA, any party who discovers the URL can POST unlimited arbitrary email addresses. Thousands of fake addresses flood the sheet, the mail quota is exhausted in seconds, and the script relays confirmations to arbitrary third-party addresses, turning the endpoint into an open spam relay with the owner's account as sender.
The service worker caches every resolved response without checking
res.ok, so error pages are cached and served to all future visitors. A botched deploy becomes permanent for everyone who visited during the window.User data from local storage is interpolated unsanitized into an HTML insertion, stored XSS.
Lesson content is interpolated into
innerHTMLwith no escaping. Low exploitability today, but if content ever moves to a CMS, a natural growth step, any crafted entry executes JavaScript in the app's origin.
⚠️ The scorecard, twenty-four days later:
| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | Paywall bypass via URL parameter | 🔴 Critical | STILL OPEN |
| 2 | Service worker caches error responses | 🟠 High | STILL OPEN |
| 3 | Content repeated after item 21 | 🟠 High | ✅ Fixed, by accident |
| 4 | Service worker resolves undefined | 🟡 Medium | STILL OPEN |
| 5 | Waitlist open relay | 🔴 Critical | STILL OPEN |
| 6 | Stored XSS via user data | 🟠 High | STILL OPEN |
| 7 | XSS surface in content rendering | 🟡 Medium | STILL OPEN |
| 8 | Weak email validation | 🟢 Low | STILL OPEN |
Seven of eight open. And the one that was fixed was fixed by accident, writing the real content happened to remove the repetition. No finding was ever fixed because it was a finding.
Why, and this is the actual lesson:
- They lived in a chat message. Messages scroll away. Nothing in the project's task list, status file, or launch checklist ever referenced them.
- No deadlines. Every finding was "should fix." None had a when.
- Nothing enforced them. No CI, no linter, no gate. Nothing could ever fail because of them.
- Features had deadlines; findings didn't. Twenty-four days of visible progress, content, art, backend, payments, onboarding, each with a launch pulling forward. A security fix produces no visible progress, so it loses every daily argument.
- No follow-up loop. The review ran once, on day one, and never again.
Note the fixes were not hard. Finding 2 is one conditional (if (res.ok)). Finding 1 is deleting a block. Finding 6 is a fifteen-line escaping helper. Difficulty was never the reason.
What the review did achieve, which is worth crediting. Everything designed after it internalised the lessons: row-level security on every table with both clauses; entitlement tables read-only to clients; webhooks that verify a secret and fail closed; idempotency enforced by database constraints; a security definer function hardened after a platform advisor flagged it.
📐 The pattern generalises: a review changes how you write new code and does nothing to old code. That's a partial win, and it's exactly why "sweep backward" belongs in the workflow.
⚠️ A second process failure with the same root. A development flag that unlocked the entire paid product for free appeared on four separate checklists and was still enabled at the end of the project. Four written reminders did not disable one boolean.
📐 Checklists are for things that cost you time. Automation is for things that cost you the business. One
grepin CI would have failed the build on every commit for 24 days.
Lessons
- ⭐ A review produces a document. A document is not a fix. Seven of eight open after 24 days.
- Convert findings to tracked items with dates, in the same session. This single step is the whole chapter.
- Work without a deadline loses to work with one. Every time.
- Difficulty is not what determines whether something gets fixed. These were one-line fixes.
- Reviews change new code, not old code. Sweep backward deliberately.
- Your convenience features are your attack surface. A test unlock parameter and a content-review flag both gave away the product.
- Client-side entitlement is a suggestion. The server must agree.
- "Only exploitable if we grow" is a finding about your growth plan.
- Never become an open relay. It spends your domain's reputation on someone else's spam.
- Automate the catastrophic checks. A grep in CI beats four checklists.
- Add a release gate. "All Critical/High closed" is a checkbox that would have caught all of this.