Nakodo has two completely different kinds of limit, and conflating them is a mistake I have watched other codebases make.
The first kind is the product: how many creators a plan contacts a day, how many AI drafts, how many campaigns. Those are business rules, they live in the database, they are on the pricing page, and going over one is a thing the user should be able to read about and fix by upgrading.
The second kind has nothing to do with the product. It is "you have tried to sign in eleven times in nine minutes" and "something is calling our AI endpoint in a loop". Those are bursts. They are measured in seconds and minutes, they have no business meaning, and they belong in a fast shared counter rather than in Postgres.
This post is the second kind: src/lib/ratelimit.ts, Upstash Redis, 18 rules, and four decisions I would defend.
The rules are one object with their reasons attached
const RULES = {
signIn: { tokens: 10, window: "10 m" }, // per email typed
signInIp: { tokens: 50, window: "10 m" },
signUpIp: { tokens: 10, window: "1 h" },
authEmail: { tokens: 5, window: "1 h" }, // confirmation and reset emails, per address
authEmailIp: { tokens: 20, window: "1 h" },
password: { tokens: 5, window: "15 m" }, // changing or confirming a password, per user
research: { tokens: 6, window: "10 m" }, // website research (crawl and AI), per user
ai: { tokens: 30, window: "1 m" }, // drafts and fit notes, per user
billing: { tokens: 20, window: "1 m" }, // anything that calls Stripe, per user
lookup: { tokens: 300, window: "1 m" }, // searches run as the user types, per user
// ...eight more
} as const satisfies Record<string, { tokens: number; window: Duration }>;
export type RateLimitRule = keyof typeof RULES;
satisfies rather than a type annotation, so Duration still validates "10 m" while the keys stay literal and RateLimitRule is the union of rule names. Every call site names a rule, and a typo is a compile error rather than a silently unlimited endpoint.
The comment on each line is the key it is counted against, which is the thing you actually need to know when reading a number. And note the pairs: signIn at 10 per email and signInIp at 50, because IP limits have to be looser than per-account ones. An office, a university or a mobile network puts a lot of people behind one address, and a limit tight enough to stop password guessing from one laptop would lock out a whole building.
The widest rule in the file is lookup at 300 a minute, which looks absurd until you remember it fires on keystrokes: it is a typeahead over our own category list, so the limit is there to catch a stuck key or a script, not to pace a human.
Keys are hashed, so the limiter never holds an identity
const hash = (key: string) => createHash("sha256").update(key).digest("base64url").slice(0, 32);
export async function rateLimit(rule: RateLimitRule, ...keys: (string | null | undefined)[]): Promise<void> {
const l = limiter(rule);
if (!l) return;
let results: Awaited<ReturnType<Ratelimit["limit"]>>[];
try {
results = await Promise.all(keys.filter((k): k is string => !!k).map((k) => l.limit(hash(k.toLowerCase()))));
} catch (e) {
console.error(`Rate limit check failed (${rule}); letting the request through`, e);
return;
}
const blocked = results.filter((r) => !r.success);
if (blocked.length > 0) {
const reset = Math.max(...blocked.map((r) => r.reset));
throw new RateLimitError(Math.max(1, Math.ceil((reset - Date.now()) / 1000)));
}
}
A sign-in attempt is counted against the email typed and the caller's IP. Both are personal data, and neither needs to be legible for counting to work: the only thing a counter needs is that the same input maps to the same bucket. So the key is hashed before it leaves the process, and what Upstash stores is a key made of our prefix, the rule name and 32 characters of base64url.
Lowercased first, so Alice@Example.com and alice@example.com land in the same bucket rather than giving somebody two budgets for free.
The practical payoff is that a system holding counters is not a system holding a list of our customers' email addresses, and the question "what happens to this data" has the boring answer: there isn't any.
Counting against every key is a deliberate choice with a cost worth naming. Because the keys run through Promise.all, an attempt blocked by the email budget still spends a token from the IP budget. For sign-in that is exactly what I want, because an attempt is an attempt and the IP budget exists to count attempts from that IP. For an expensive read where the second key is only a safety net, you would want the cheap check first and a short circuit. Which behaviour you want depends on whether a key is a cost or a counter.
It fails open, on purpose, with a one second budget
l = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(tokens, window),
prefix: `nakodo:rl:${rule}`,
timeout: 1000,
analytics: false,
});
Two layers of failing open there. timeout is Upstash's own, documented as:
If set, the ratelimiter will allow requests to pass after this many milliseconds. Use this if you want to allow requests in case of network problems. @default 5000
Five seconds is too long to add to a sign-in attempt, so it is one. And underneath it, the catch in the function above swallows anything else: a bad token, Upstash's own plan limit, DNS, a region outage. Both paths let the request through and log.
That direction is the whole argument. Rate limiting protects sign-in, the AI endpoints and Stripe calls from bursts. If Redis being unhappy made those endpoints fail, then an outage in the protection would become an outage in the product, and the thing I was defending against (people unable to use the app) would be caused by the defence. The cost of failing open is a window where a burst is not caught. The cost of failing closed is that nobody can sign in. Those are not comparable.
The same logic extends to configuration. With no UPSTASH_REDIS_REST_URL and token, limiter() returns null and nothing is limited at all, which is the default locally and on preview deployments. Being wrong about that in production would be bad, so it says so:
if (!redis && isLiveDeployment()) {
console.error("Rate limiting is off: set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.");
}
One limiter per rule is memoized in a module-level Map rather than constructed per call, and that is not only allocation. From the same docs, on the in-memory cache of already-exhausted identifiers:
In serverless environments this is only possible if you create the ratelimiter instance outside of your handler function. While the function is still hot, the ratelimiter can block requests without having to request data from redis.
A limiter built inside the handler throws that away on every request, so a client hammering an endpoint pays for a Redis round trip each time instead of being turned away from memory.
One rule set, three shapes, because callers report failure differently
This is the part I would lift into another project unchanged. Three kinds of code call these limits, and each has its own convention for failure.
// Throws, for code with a UserError convention.
export async function rateLimit(rule, ...keys): Promise<void>
// The message to show, or null: for Server Actions that return errors as values.
export async function rateLimitMessage(rule, ...keys): Promise<string | null>
// A 429 with Retry-After, or null: for route handlers.
export async function rateLimitResponse(rule, ...keys): Promise<Response | null>
The second and third are four lines each, both wrapping the first. No policy is duplicated, and no caller has to translate.
Our Server Actions return expected failures as values rather than throwing, so a limit there has to become a string in the same shape as every other refusal. Route handlers want the HTTP answer, and they want it with a hint:
return new Response(e.message, { status: 429, headers: { "Retry-After": String(e.retryAfterSeconds) } });
And RateLimitError extends UserError, our class for messages that are safe to show as they are, so an action that already renders UserError messages renders this one without knowing it exists.
The message itself is written for a person rather than a monitoring dashboard:
function waitText(seconds: number): string {
if (seconds < 60) return seconds <= 1 ? "a second" : `${seconds} seconds`;
const minutes = Math.ceil(seconds / 60);
return minutes === 1 ? "a minute" : `${minutes} minutes`;
}
"Too many attempts. Try again in 3 minutes." Rounded up, never "in 121 seconds", and never a bare 429.
The IP key, and the one thing you must not trust
// The caller's IP as a rate-limit key. On Vercel, x-real-ip and the first
// x-forwarded-for entry are set by the platform and can't be spoofed by the
// client. Locally neither is set, and the key is skipped.
export async function ipKey(): Promise<string | null> {
const h = await headers();
const ip = h.get("x-real-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim();
return ip ? `ip:${ip}` : null;
}
Taking the first entry of x-forwarded-for is only safe because the platform rewrites that header. If you run behind something that appends rather than replaces, the first entry is whatever the client sent, and your per-IP limit becomes a per-made-up-string limit, which is no limit at all. Hosting-specific, worth a comment, worth checking rather than copying from a blog post. Including this one.
The endpoints these guard are all behind a session, so there is not much to show from outside. You can at least watch the order of the checks: the session comes before the limiter, so a signed-out request never reaches it.
$ curl -s -o /dev/null -w "%{http_code}\n" "https://nakodo.app/api/campaigns/abc/changes"
401
If you want the product-level limits instead, the ones with actual meaning, they are the numbers on the pricing page, counted per plan rather than per ten minutes.
Top comments (1)
Fail-open on a burst limit is a defensible call — dropping the odd login no one is abusing beats locking everyone out — and splitting business limits (Postgres) from burst limits (Redis) is the right line. Where I would be careful is the word “fails”: there is a second failure mode that looks like fail-open and is not benign. When an authorization predicate depends on a context value (tenant id, user id) read from session state, missing fails closed (comparison is null → zero rows) but stale fails open — a connection returned to the pool with the previous tenant’s value still set will answer with the previous tenant’s rows. That is the one context you cannot afford to fail open on, so make it transaction-scoped (
set_config(..., true)inside a transaction) and wipe it on release (DISCARD ALL). Hashing keys before they leave the process is the other good instinct; I would hash and scope them (include route/purpose) so a key that lands in a log cannot be replayed against a different endpoint. Two-account probe for the authz half: github.com/cekuu35/supabase-rls-le...