skip to content

Building a Small Simple Comment System

Notes from building my own comments service, threads, replies, likes, moderation, and the browser security work around them.

11 min read

What I built and why it’s tiny

I built a comments service for my own site and kept the scope small on purpose. Someone signs in, opens a thread, writes a comment, replies, leaves a like. That’s it. Behind that though I still wanted real moderation, session control, and browser security work that holds up.

Small here doesn’t mean flimsy. Small means I can hold the whole service in my head, read the request path end to end in one sitting, and fix problems without hunting across six systems to find where something went sideways.

The rules I set early

I picked a few limits at the start and stuck to them. Every blog post gets exactly one thread, sourced from the site RSS feed. Replies are just comments with a parentCommentId and a depth. Markdown is allowed, raw HTML isn’t.

And every single write goes through Origin checks, CSRF validation, and auth. No exceptions, no “well this one route is internal.”

Those limits are what keep the thing easy to work on. They cut off hidden scope, which for a comments tool matters way more than how many features it has.

How a request actually moves

The schema matters but honestly the request flow explains the service better.

Someone opens a post. The frontend asks the service to map the post slug to a thread, then loads that thread’s comments. If they’re signed in, the response also carries whether they liked each comment.

Login is GitHub OAuth with PKCE. The service stores temporary OAuth state, catches the callback, upserts the user, creates a session row, sets a session cookie.

Every write walks the same gate in the same order:

  • Check the Origin.
  • Check the CSRF token.
  • Check the session.
  • Apply rate limits.
  • Run the write.

Cheap rejection first, stateful work last. That ordering is doing more security work than any individual check in the list.

The tables

Each one does one thing.

User stores GitHub identity and moderation flags. Session stores server-side session state, including expiresAt, revokedAt, and lastUsedAt.

Thread maps a (siteKey, resourceType, resourceId) tuple to one thread. Comment stores the parent pointer, depth, markdown body, rendered HTML, and edit or delete timestamps.

CommentReaction stores likes with a unique (commentId, userId, reaction) key. OAuthState holds short-lived PKCE state with codeVerifier and returnTo. PrebannedUser blocks identities before they’ve even logged in once.

That’s the whole feature set. I could add more tables but more tables wouldn’t make this safer or easier to run, so I haven’t.

Deletes are soft first

Each comment row stores deletedAt and deletedBy, and a cron job hard deletes anything older than 72 hours.

That window gives moderators room to react without instant data loss, and the cron keeps the live tables from filling up with junk.

Resolving threads against RSS

The resolve endpoint takes siteKey, resourceType, and resourceId and gives back a threadId. The upsert itself is boring. The check around it is the fun part.

The service fetches the site’s RSS feed, pulls the valid slugs out, and only creates threads for posts that actually exist. So somebody throwing random slugs at the endpoint can’t stuff my database full of junk rows.

Resolve thread latency, RSS fetch and parse, illustrative ms
Loading chart…

Why PKCE fits here

PKCE is the right shape for a public web login flow. The browser never holds a secret, but the callback can still prove it belongs to the flow that started earlier.

The start route generates state, codeVerifier, and codeChallenge, stores the verifier and return path in OAuthState, then redirects to GitHub with the state and challenge.

The browser stays dumb and the sensitive exchange stays on the server, which is exactly the split you want.

Validating where you get sent after login

Every OAuth flow needs somewhere safe to drop the user afterward. I validate returnTo against known blog origins plus the service origin.

Skip that check and your login flow doubles as an open redirect, which is not a trade I’m making for a comments box.

Sessions live in Postgres

The cookie is nothing but a pointer: lh_comments_session=<uuid>.

On each authenticated request the service reads the cookie, loads the session row, checks revocation, checks expiry, and returns the user. lastUsedAt updates in the background.

I picked this over JWTs on purpose. Revoking a session is a row update. Banning someone is a row update. Invalidating a session doesn’t need extra token rules or a denylist to go with it. That kind of boring is worth a lot when you’re the only person maintaining the thing.

Gating writes

Every write checks Origin, even with CORS configured. In production the service demands an Origin header and rejects anything outside the allowlist. The server owns the write boundary, full stop.

// Pseudocode shaped like the real route guard
export async function mutationAllowed(request: NextRequest) {
const origin = request.headers.get('origin')
if (env.NODE_ENV === 'production') {
if (!origin) return { ok: false, code: 'MUTATION_ORIGIN_REQUIRED' }
if (!isAllowedOrigin(origin)) return { ok: false, code: 'MUTATION_ORIGIN_NOT_ALLOWED' }
} else {
if (origin && !isAllowedOrigin(origin)) {
return { ok: false, code: 'MUTATION_ORIGIN_NOT_ALLOWED' }
}
}
// CSRF check happens here too
return { ok: true }
}

The CSRF part

It’s a cookie plus a request header. The cookie stores csrf_token=<random> and the client sends the same value back in X-CSRF-Token.

The server checks presence, equal length, and constant-time equality, since failure timing shouldn’t leak anything about the token’s shape.

const CSRF_COOKIE = 'csrf_token'
export async function verifyCsrf(request: NextRequest) {
const cookieToken = (await cookies()).get(CSRF_COOKIE)?.value
const headerToken = request.headers.get('x-csrf-token')
if (!cookieToken || !headerToken) return false
if (cookieToken.length !== headerToken.length) return false
// Constant-time compare avoids timing leaks
return crypto.timingSafeEqual(
Buffer.from(cookieToken),
Buffer.from(headerToken)
)
}

How the client gets that token

The /v1/me endpoint returns the current user plus a csrfToken. The client calls it on load, keeps the token in memory, and attaches it to every write.

Keeps the write path obvious. No component is quietly leaning on hidden state to get its token.

Where things actually break

Almost never exotic attacks. Almost always ordinary browser problems. A new origin missing from the allowlist. One request forgetting the CSRF header. Cookies quietly not crossing the boundary after http and https got mixed. A write firing before the /v1/me request came back.

The narrow flow makes all of those easy to track down, and once the request path is solid the work gets dull in the best possible way.

Blocked mutation reasons, illustrative counts
Loading chart…

Read latency

One chart carries both the median and the tail. Hit the Δ Compare toggle to shade the gap between p50 and p95, or open the table view to read the raw days.

List comments latency, p50 and p95, illustrative ms
Loading chart…

Most of the read wins came from just doing less work. The service returns bodyHtml instead of rendering markdown on every client load. Likes get grouped into one query. Response shapes stay consistent so the frontend never needs follow-up fetches for a normal list view.

The tail is where you learn things though. Spikes usually mean cold starts, slow database setup, or a big thread without a proper limit on it. Watch those numbers, because readers feel tail latency before they feel anything else.

Write latency

Create comment latency, median-ish, illustrative ms
Loading chart…

Writes cost more and that’s fine. They include the markdown render, the sanitizing, and the whole stack of checks.

On a blog, reads are the common path and they should stay cheap. A heavier write path is a totally acceptable trade for that.

Error rate

Error rate, percent, illustrative
Loading chart…

That day 4 bump matches the failures I’d expect in practice. An RSS fetch hiccup, an allowlist mismatch after a domain change, or a client request missing credentials: 'include'.

The stuff that actually gave me trouble

None of it was the SQL. Basically everything that broke came from browser state and cross-origin rules.

Cookies across multiple origins

Same three questions every time. Is the blog on HTTPS? Is the service on HTTPS? Is the browser actually sending credentials?

Most “random” 401s aren’t random. A cookie failed to cross the boundary. Every time.

CSRF token ordering

A frontend that posts before /v1/me resolves is supposed to fail, and it does. That failure is surprising exactly once, and then the strict write path makes sense forever after.

Allowlist drift

Preview domains come and go, and every missing allowlist update makes writes fail. The fix is always small but it’s a good reminder that origin policy needs one source of truth instead of three.

Rate limits with more than one instance

The rate limiter right now is an in-memory map, which works great until there’s more than one instance.

That one’s a known ceiling, not a bug. I’ll fix it when the traffic makes me.