MUHIT

Notes on software, systems, and the things that break.

Designing a comment system that survives contact with readers

4 min read#architecture#security

Most "blog with comments" examples are tutorials in disguise: they demonstrate the happy path and leave the sharp edges for you to discover in production. This site started from one of those examples, so this post documents what changed and why.

The three problems

A comment system is not one feature. It is three, and they fail independently:

  1. Identity — who is allowed to write, and how do you know?
  2. Storage — where does the text live, and can you delete it precisely?
  3. Moderation — what happens when the answer to (1) is "someone annoying"?

The original template solved all three, but each with a shortcut that only holds up under light use.

Identity: the token never needs to reach the browser

The template used a single-page-app SDK, which meant the browser held an access token and forwarded it to the API on every request. The API then called Auth0's /userinfo endpoint to find out who was calling.

That works, but it puts a bearer token somewhere it does not need to be. This site uses the server-side SDK instead: the session lives in an encrypted, HttpOnly cookie, and the route handler reads it directly.

// app/api/comments/[slug]/route.ts
const viewer = await getSessionUser();
if (!viewer) {
  return NextResponse.json({ error: "Sign in first." }, { status: 401 });
}

No token in localStorage, no /userinfo round trip, and no chance of accidentally logging the credential.

Storage: delete by id, not by value

This is the one that quietly corrupts data. The template stored comments in a Redis list and deleted them like this:

await redis.lrem(url, 0, JSON.stringify(comment));

That removes every element whose serialised form matches exactly. If the stored JSON and the reconstructed JSON differ by one byte — a key order, an added field, a timestamp precision — the delete silently succeeds and removes nothing. It also cannot distinguish two identical comments.

The fix is to stop treating the payload as the identifier. A Redis hash keyed by comment id makes deletion exact and single:

async remove(slug, id) {
  const removed = await redis.hdel(`comments:${slug}`, id);
  return removed > 0;
}

Authorisation: decide on the server, tell the client the answer

The template shipped each comment's author sub and email address to every visitor, then let the client decide which delete buttons to render. The button was hidden, but the API trusted the same client-supplied object.

Two rules here instead:

  • The API never returns sub or email. It returns canDelete, computed server-side from the session and the stored comment.
  • The delete endpoint re-checks ownership from storage. A hidden button is a UI affordance, never a permission.
const canDelete = isAdmin(viewer) || viewer.sub === comment.author.sub;
if (!canDelete) {
  return NextResponse.json({ error: "Not allowed." }, { status: 403 });
}

Where comments are keyed

The template decided which post a comment belonged to by reading the Referer header. That is a client-controlled value: any caller can set it to another post's URL and read or write that thread.

Keying by the slug in the route path is not clever, but it is unambiguous:

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ slug: string }> },
) {
  const { slug } = await params;
  if (!isValidSlug(slug)) {
    return NextResponse.json({ error: "Unknown post." }, { status: 400 });
  }
  // ...
}

The part that is genuinely hard

None of the above is difficult. The difficult part is what the code cannot do for you:

Concern Handled by Still your job
Spam rate limiting reading the queue
Abuse delete + ban the account deciding when to ban
GDPR deletion delete by id answering a deletion request
Notification — noticing comments exist

Sign-in requirement is the cheapest spam control that actually works, which is why most platforms converge on it. Everything past that is attention, not code.

A comment system is a small piece of software and a long-term commitment to reading what people write to you.

If you are building one: pick the identity model first. It constrains everything else.

Comments

Loading comments…