All posts
securitypostgres

Rate limiting and audit logs: the boring security you actually need

Not CAPTCHAs, not 2FA — the two unglamorous features that stop the most common abuse and the most common 'wait, who did that?' support ticket.

When people say "we need to think about security," they usually mean CAPTCHAs and two-factor auth. Those matter, but two much less exciting features stop more real incidents.

Rate limiting: one table, one function

A sliding-window counter backed by a single Postgres table handles almost every abuse case a starter needs to worry about — contact-form spam, credential-stuffing against login, brute-forcing a password reset:

export async function limit(key: string, { max, windowMs }: { max: number; windowMs: number }) {
  const now = Date.now();
  const row = await db
    .insert(actionRateLimits)
    .values({ key, count: 1, lastRequest: now })
    .onConflictDoUpdate({
      target: actionRateLimits.key,
      set: { count: sql`${actionRateLimits.count} + 1`, lastRequest: now },
    })
    .returning();
  // ...compare row.count against max within the window
}

No Redis, no external service — a Postgres upsert with an ON CONFLICT clause, keyed by IP for anonymous endpoints and by user id for authenticated ones.

Audit logs: append-only, free-form action strings

The temptation is to model every possible action as an enum. Don't — a dot-namespaced free-form string ("user.banned", "billing.subscription.started") means a new feature's first audit call site never needs a migration:

await logAudit({
  actorId: admin.id,
  action: "user.banned",
  targetType: "user",
  targetId: userId,
  metadata: { reason },
});

The value isn't in querying it programmatically (though you can) — it's in being able to answer "who did that, and when" during an incident without grepping application logs across three services.

Neither of these is impressive in a demo. Both are the difference between a five-minute investigation and a multi-hour one.