2026-07-15

Comment Moderation and Rate Limiting: Closing the Loop

When I turned comments on, I wrote down two limitations on purpose. There was no moderation. Deleting spam meant opening the Neon console and running SQL by hand. And there was no rate limiting. Cloudflare Turnstile blocks most bots, but nothing stopped one determined visitor, or a bot that solves challenges, from posting as fast as it could.

Both gaps are now closed, and this post walks through how.

Moderation without building a login system

The obvious version of "add moderation" is a full admin system: accounts, sessions, roles. For a personal site with exactly one moderator, that's a lot of machinery to guard one page.

Instead, the admin surface is a single long random token stored in an environment variable, COMMENTS_ADMIN_TOKEN. There's a private page at /admin/comments where I paste the token once per browser session. It shows every comment across every post, grouped by post, each with a delete button. The page tells search engines not to index it, and knowing the URL gets a visitor nothing but a password field.

The API side is a new route at /api/admin/comments. Both its endpoints check an Authorization: Bearer header against the token before doing anything:

  • GET returns all comments across all posts
  • DELETE ?id= removes one comment

One detail worth copying if you build something similar: the token comparison hashes both sides and uses Node's timingSafeEqual instead of ===. A plain string comparison returns faster the earlier the first wrong character appears, which in theory lets an attacker probe the token one character at a time. Constant-time comparison closes that door, and it costs three lines.

Rate limiting without storing IPs

The rate limit rule is simple: more than five comments from the same visitor within ten minutes gets a polite refusal and HTTP 429.

The interesting part is how "same visitor" works. Storing raw IP addresses in the database is a privacy liability I don't want, so each comment stores an HMAC-SHA256 hash of the IP instead, keyed with a secret salt (COMMENTS_IP_HASH_SECRET). The database alone can't be reversed into addresses, but two comments from the same IP produce the same hash, which is all a rate limiter needs:

SELECT COUNT(*) FROM comments
WHERE ip_hash = $1
  AND created_at > now() - interval '10 minutes'

Two smaller decisions hiding in there:

  • The check runs before Turnstile verification, not after. Turnstile tokens are single-use. If the rate limiter rejected you after verification, it would burn a challenge you legitimately solved, and you'd have to solve another one just to see the same error again.
  • The new column arrived the same way the table did. The original design creates the comments table on first request with CREATE TABLE IF NOT EXISTS. The new ip_hash column follows suit with ALTER TABLE ADD COLUMN IF NOT EXISTS. No migration tooling, which is the right amount of tooling for a one-table schema.

Existing comments keep a null hash and are simply never rate-limit candidates. Graceful degradation applies here too: if the salt variable is missing, comments still post, just without the limiter.

A small honesty fix in the UI

While I was in the comment component, I fixed a shortcut from the first version. After you posted a comment, the UI appended it locally with a made-up id and timestamp instead of asking the server what it actually saved. Now it re-fetches the list after a successful post, so what you see is exactly what the database holds. The optimistic append survives only as a fallback if that refresh fails.

How I tested it

CAPTCHAs are designed to stop automation, which makes automated testing of everything behind one awkward. The trick is that the rate limiter runs before the CAPTCHA check, so its behavior is observable with fake tokens:

  1. Seed five comments carrying the hash of a test IP directly into the database.
  2. POST from that IP with a fake Turnstile token. Result: 429, rate limited.
  3. POST from a different IP with a fake token. Result: 403, CAPTCHA failed. That proves the limit is per-visitor, not global, and that the route's hashing matches the seeded hashes.
  4. Exercise the admin API: no token and wrong token get 401, the real token lists everything, delete removes each seed row, and deleting a nonexistent id gets 404.

Then the seed data gets deleted through the same admin endpoint it was built to test.

The PM angle

The last post argued that "done" should include configuration. This one is about the other thing that definition usually misses: operability. A feature isn't just what visitors touch. It's also what the person running it needs when things go wrong. The first spam comment is a bad time to start designing your moderation tools, the same way an outage is a bad time to start thinking about logging.

Shipping comments with these gaps was still the right call. The feature proved itself with real use first, and the limitations were written down publicly, not discovered later. That's the difference between technical debt and a planned backlog: one is a surprise, the other is a queue. The discipline isn't avoiding gaps. It's naming them at ship time and closing the loop while they're still cheap.

Comments

Loading comments...