The Security I Actually Built Into a Solo Site
Part of Building This Site and Small Business Websites
By Paul Peery · August 1, 2026 · 4 min read

Nobody attacks a small site for who you are. They attack it because it's there — bots probe every form on the internet, scanners hit every endpoint, and a server that fetches URLs will eventually be asked to fetch something it shouldn't. I built this site alone, so every defense had to be something one person can build, test, and then stop thinking about.
Here's what's actually running in production — the threats and the code-level answers, so you can copy the approach.
Threat 1: my own server, aimed at things it can reach
The admin panel has an "import a deal from a URL" feature: paste a product page, the server fetches it, the AI drafts the listing. Server-side fetching of user-supplied URLs is a classic vulnerability called SSRF — server-side request forgery. The danger isn't the public internet; it's what your server can reach that the public can't: cloud metadata endpoints like 169.254.169.254 (which on many hosts will hand over credentials), internal services, localhost.
My importer refuses all of that, in layers:
- HTTPS only, no credentials in the URL, no custom ports — rejected before any network activity.
- Resolve the hostname once, then vet every address against a blocklist: loopback, private ranges, link-local (that's the metadata range), carrier-grade NAT, and their IPv6 equivalents — including IPv4-mapped IPv6 forms, a favorite trick.
- Pin the DNS answer. This is the subtle one. A classic bypass called DNS rebinding answers the check with a safe public IP and the fetch with a private one — the attacker controls the DNS server, so they can change the answer between your two lookups. My fetcher freezes the vetted addresses into the connection itself; the OS resolver is never consulted a second time. TLS still validates against the hostname.
- Redirects re-run the whole gauntlet. Every hop gets re-validated and re-pinned — otherwise a safe URL just 302s to the metadata endpoint.
- An 8-second deadline and a hard byte cap, streaming, so a hostile server can't hold a connection open or flood memory.
Every fetch of an outside URL goes through this one code path. That's the rule that makes it maintainable solo: there is exactly one door, and the tests hammer that door — including live-socket tests that spin up a real local server and try the rebinding trick.
Threat 2: forms, and the bots that live in them
Every public form — contact, newsletter, project intake — gets probed within days of existing. The naive answer is a CAPTCHA on everything, which punishes every human to inconvenience some bots. My spam stack costs real visitors nothing:
- A honeypot field invisible to people, irresistible to form-filling bots. Bots that fill it get a fake success — no error to learn from.
- A time trap. A form submitted two seconds after page load wasn't filled in by fingers.
- Database-backed rate limiting. Every sensitive action claims a slot in a
rate_limitstable via a single atomic upsert — one statement, so it's race-safe even on a serverless database connection with no transactions. Buckets and windows differ per action (sign-ins, comments, AI calls, checkout sessions all have their own). - Validation before the rate limit. Junk input never burns a slot — so a bot flooding garbage can't lock out the real visitor who shows up afterward.
- Optional CAPTCHA last. Cloudflare Turnstile can switch on for the auth and intake forms with two environment variables, and the site's health page now warns if it's ever half-configured (which would lock everyone out — ask me how I know to check for that).
Threat 3: paid files that must stay paid
The shop delivers digital downloads. The file lives in blob storage, and the naive implementation — link straight to the storage URL — means one buyer can share the link forever.
Instead, the storage URL never appears on any page. Downloads go through a signed link: an HMAC token bound to a specific payment record, and the route checks that the payment is still paid before redirecting. Refund the purchase and the webhook flips the record — the same link now returns nothing. No sessions, no state, just a signature and a database row that must agree.
One hard-won detail: when comparing secrets or tokens, use a constant-time comparison — and compare byte lengths first, not string lengths. A multibyte character in a forged token once turned my clean 401 into a crash, because string length and byte length disagree in JavaScript. Failed closed, but a 500 is a worse answer than a 404.
Threat 4: everything else, caught by defaults
- A Content-Security-Policy allowlist naming exactly the third-party script hosts allowed (analytics, ads, CAPTCHA) — a compromised dependency that tries to phone home anywhere else gets blocked by the browser.
- Secrets never reach logs. Email sending in production throws rather than logging a payload, because those payloads contain sign-in links. Error loggers record shapes and codes, never keys.
- Every admin action and every admin page checks the admin role itself — not just a layout or middleware wrapper. Modern frameworks skip re-rendering layouts on navigation, so a check that lives only in the layout is a check that sometimes doesn't run. Guard at the data, not the wrapper.
The part that makes it stick
None of this survives on discipline. All of it is enforced by tests that run on every push — the rate limiter's atomicity against a real database, the SSRF guards against live sockets, the security headers, the download-link refusal paths. A solo site can absolutely have real security; what it can't have is security that depends on you remembering things.
If you're building something like this for your own business and would rather not learn it the hard way, that's literally what I do — or start a conversation. More build write-ups live under Building this site.
Keep reading
All postsComments
No comments yet — be the first!
