DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A student house is one IP address, so our rate limit counts licences

Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. The app never sends that email itself. It POSTs to one endpoint on our server, /api/notify, with the licence it holds, and the server decides what to send.

The reason the app holds no mail credential at all is a separate post: a local app cannot keep a secret from its owner. This one is about the endpoint that resulted, and specifically about the one line in it I would defend hardest in review.

The line

// Rate-limit per license (falling back to IP). Keying by token means users
// behind a shared/NAT IP each get their own budget instead of competing, and
// it no longer shares a bucket with /api/validate.
const ip =
  req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
const { success: withinLimit } = await ratelimit.limit(`notify:${token || ip}`);
if (!withinLimit) {
  return NextResponse.json({ error: "Too many requests." }, { status: 429 });
}
Enter fullscreen mode Exit fullscreen mode

The default rate limit key in every tutorial is the IP address, and for Notifio that is close to the worst available choice.

Think about who buys this app. Students hunting a room in Amsterdam or Leiden. People in a shared house, a student residence, a university network, a coworking space. Several of our users are behind one NAT gateway on purpose, and in the case that matters most they are hunting the same city at the same time of year. Key the limit by IP and the six people in a student house share one budget of twenty requests per ten seconds, so the sixth person's alert email is dropped because of what their housemates did. They are not abusing anything. They are the ideal customer, six times.

Key it by licence token instead and every buyer gets their own budget, which is also the thing we actually want to bound: a single licence sending a suspicious volume of mail is worth stopping, and a single building is not.

The fallback to IP is for the request that arrives with no token. Those requests are rejected a few lines later anyway, so this only stops an unauthenticated flood from being counted as one unlimited bucket.

The prefix is part of the design

The notify: prefix looks decorative. It is not.

// 20 requests per 10 seconds, shared by validate and notify.
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, "10 s"),
  analytics: false,
  prefix: "notifio:rl",
});
Enter fullscreen mode Exit fullscreen mode

That limiter instance is shared between /api/validate and /api/notify, which is fine for the Redis connection and wrong for the counter. The app calls validate on launch and periodically after that to confirm the licence is still active. It calls notify when a listing appears. Without a prefix on the key, a burst of validations eats the budget for sending an alert, and the failure surfaces as a missing email from a completely unrelated code path.

One limiter object, separate key namespaces per endpoint. Cheap to write, and it makes the two features independent for the only thing they were competing over.

Four kinds of mail, one door

The endpoint carries a type, and the payload for each type is validated before anything is sent:

interface NotifyPayload {
  email: string;
  token: string;
  type?: "listings" | "auth_failure" | "blocked" | "reply";
  alerts?: AlertEntry[];
  authFailureSite?: string;
  blockedSite?: string;
  reply?: ReplyNotice;
}
Enter fullscreen mode Exit fullscreen mode

Four messages the user can receive: new listings, a site that needs them to log in again, a site that is refusing automated checks, and confirmation that an auto-reply went out. All four go through one route because all four need the same three things first: a rate limit, a licence check, and a sender identity that belongs to us rather than to the user's machine.

The licence check is deliberately uninformative when it fails:

if (!license || license.email !== email || !license.active) {
  return NextResponse.json(
    { error: "Invalid or inactive license." },
    { status: 403 }
  );
}
Enter fullscreen mode Exit fullscreen mode

Three different failures, one message. Telling a caller that the token is real but the email is wrong turns one guess into two, and nothing in the app needs to know which half was wrong.

Shape validation happens before any send, with its own reason:

// Validate shape so a malformed payload can't throw a 500 mid-request.
if (!alerts.every((a) => a && typeof a.siteName === "string" && Array.isArray(a.newListings))) {
  return NextResponse.json(
    { error: "Each alert requires siteName and newListings[]." },
    { status: 400 }
  );
}
Enter fullscreen mode Exit fullscreen mode

A 500 halfway through is the one outcome that is genuinely ambiguous. Did the mail go out or not? Checking the shape up front means every path from here either sends or explains itself.

The listing titles are somebody else's text

This is the part that would be easy to miss. The titles and URLs in an alert email were read off a third party's search results page minutes earlier. They are untrusted input, and they are being interpolated into an HTML email:

function escHtml(str: string): string {
  return String(str)
    .replace(/&/g, "&")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
Enter fullscreen mode Exit fullscreen mode

Every listing title and URL goes through that on the way into the template. A landlord who puts an angle bracket in a headline is common. A listing title engineered to break out of an attribute is unlikely, and the cost of assuming it never happens is an email client rendering arbitrary markup in a message our domain signed. Four replacements is not a price worth negotiating over.

Each email type ships an HTML body and a plain-text twin, built by a matching pair of functions, so the message survives a client that will not render HTML and reads sensibly in a notification preview.

A send failure is reported, not swallowed

if (!success) {
  console.error("[notify] Listing alert email failed:", error);
  return NextResponse.json({ error: "Failed to send email." }, { status: 502 });
}

return NextResponse.json({ sent: true });
Enter fullscreen mode Exit fullscreen mode

502 rather than 200, because the app's behaviour depends on the answer. Notifio only writes a listing into its "already seen" baseline after the alert has actually gone out. If this endpoint returned a cheerful 200 on a failed send, those listings would be recorded as seen and never alerted again, which is the one bug this whole product cannot survive.

The app side is the mirror image of that, and deliberately asymmetric for the reply email:

/**
 * Tell the user their reply went out. One email per successful reply, and the
 * only record of it they are expected to need.
 *
 * Never throws: the reply itself has already happened by the time this runs, so
 * a failed email must not turn a successful send into a logged failure.
 */
Enter fullscreen mode Exit fullscreen mode

Alert mail is load bearing, so a failure has to propagate. The reply receipt describes something that already happened on a website, so a failure there must not be allowed to rewrite history in our own ledger.

What generalises

Rate limit the identity you are actually protecting. IP is a proxy for "a user" that fails in exactly the population you most want to serve. If you sell to students, offices, schools or anyone behind carrier NAT, an IP-keyed limit is a shared punishment.

Namespace your counters per endpoint even when the limiter is shared. Otherwise your cheapest, most frequent call quietly starves your most important one.

Never return success for work you did not do. An endpoint that sends mail owes its caller the truth about whether the mail left, because the caller is usually about to record something permanent on the strength of it.

See it for yourself

The emails described here are the ones a buyer actually receives, and what triggers each of them is written up on the help page. The per-site pages explain which sites need a logged-in session, and therefore which ones can send you that "log in again" mail, for example Kamernet and WG-Gesucht. Pricing is a single one-time licence at notifio.app/pricing, and the app is at notifio.app/download.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.