Every request that reaches Storyie's Hono API at /api/v1/* eventually wants a session. Auth middleware calls into Better Auth, which resolves a cookie or Bearer token into a user. That work is real: headers, a session store, a user mapping. If you put that check first, an attacker spraying invalid credentials still forces the expensive path on every attempt — and you pay for it whether they succeed or not.
We wanted the opposite shape: a cheap, early reject that drops excess traffic before session lookup, without baking Redis into the first version, and without teaching the Expo client a different budget than the server. This post is how that small rate limiter is built, why it sits ahead of auth, and what we accepted about in-memory limits.
For the broader fail-closed auth story — global authMiddleware, public allowlist, one error envelope — see Protected by Default: A Hono API That Fails Closed.
TL;DR
- Rate limit runs before auth.
app.use("*", rateLimitMiddleware())is registered ahead ofauthMiddleware, so brute-force is throttled before any session DB work. - Defaults match the Expo client: 100 requests / 60 seconds, sliding window, same
RATE_LIMIT_EXCEEDED/ 429 shape — well-behaved clients never hit the server ceiling. RateLimitStoreis swappable. Middleware talks tohit/reset; swapInMemoryStorefor Redis/KV later without rewriting the guard.- Key = first IP in
x-forwarded-for. Safe only behind a trusted CDN (Cloudflare in production) that strips client-supplied prefixes. - Memory store is honest about limits: per-process counters, not a global cluster quota. Dedicated stores keep per-route budgets independent (e.g. reports at 10 / min).
- Why: auth is the expensive check; rate limiting is the cheap one. Order them so the cheap check protects the expensive one.
Why rate limiting runs before auth
Mount order in app.ts is the whole design:
// apps/web/app/api/v1/app.ts (excerpt)
export const app = new Hono<{ Variables: ApiVariables }>().basePath("/api/v1");
// Rate-limit runs BEFORE auth so that unauthenticated brute-force attempts are
// throttled without triggering DB auth queries. Defaults: 100 req / 60 s per IP
// (matches the client-side RateLimiter in apps/expo/services/apiClient.ts).
app.use("*", rateLimitMiddleware());
// Auth guard: every route is protected by default.
// Public paths are declared in middleware/auth.ts PUBLIC_PATHS allowlist.
app.use("*", authMiddleware);Auth middleware always hits session resolution for non-public paths:
// apps/web/app/api/v1/middleware/auth.ts (excerpt)
export const authMiddleware: MiddlewareHandler<{ Variables: ApiVariables }> = async (c, next) => {
if (PUBLIC_PATHS.has(c.req.path)) {
return next();
}
const { user, error } = await authenticateApiRequest(c.req.raw as unknown as NextRequest);
if (error || !user) {
const body: ApiErrorBody = {
error: "Unauthorized",
code: "AUTH_FAILED",
message: "Authentication required",
};
return c.json(body, 401);
}
c.set("user", user);
await next();
};authenticateApiRequest resolves the Better Auth session (cookie on web, Bearer on mobile) via getCachedUser(). That is the work we do not want to do for every spray of junk requests.
The rate-limit middleware documents the ordering decision in its own header comment:
// apps/web/app/api/v1/middleware/rate-limit.ts (excerpt)
/**
* Placed BEFORE authMiddleware in app.ts so that:
* - Unauthenticated brute-force attempts are throttled before auth DB queries run.
* - Auth errors and rate-limit errors are independent concerns.
*
* Exceeding the limit returns 429 with the unified error envelope.
*/Independence matters as much as cost. A rate-limit rejection is 429 with RATE_LIMIT_EXCEEDED. An auth rejection is 401 with AUTH_FAILED. Neither middleware needs the other's outcome, so debugging stays boring: you know which gate fired.
Client–server alignment: same budget, different keys
The Expo apiClient already rate-limits on the device before it opens a connection:
// apps/expo/services/apiClient.ts (excerpt)
constructor(rateLimiterConfig?: RateLimiterConfig) {
this.baseUrl = Constants.expoConfig?.extra?.WEB_URL || "https://storyie.com";
this.rateLimiter = new RateLimiter(
rateLimiterConfig?.maxRequests ?? 100,
rateLimiterConfig?.windowMs ?? 60000
);
}And it refuses to proceed with the same error code the server uses:
// apps/expo/services/apiClient.ts (excerpt)
if (!options.skipRateLimit && !this.rateLimiter.canProceed(path)) {
const retryAfter = this.rateLimiter.getRetryAfter(path);
const retryAfterSeconds = Math.ceil(retryAfter / 1000);
throw new ApiError(
`Rate limit exceeded. Please try again in ${retryAfterSeconds} seconds.`,
"RATE_LIMIT_EXCEEDED",
429,
{
retryAfter,
retryAfterSeconds,
endpoint: path,
}
);
}Server defaults are intentional mirrors of that client:
// apps/web/app/api/v1/middleware/rate-limit.ts (excerpt)
export interface RateLimitOptions {
/** Maximum requests allowed per window per key. Default: 100 (matches client RateLimiter). */
maxRequests?: number;
/** Window duration in milliseconds. Default: 60000 (matches client RateLimiter). */
windowMs?: number;
/** Derive the rate-limit key from the request. Default: IP address from x-forwarded-for. */
keyFn?: (req: Request) => string;
/** Pluggable store. Default: shared InMemoryStore. */
store?: RateLimitStore;
}The alignment is capacity and vocabulary, not keying strategy. The client keys by normalized endpoint (UUIDs and numeric IDs collapsed to :id) so one buggy screen cannot burn the whole app's budget on a single path family. The server keys by IP so it still throttles clients that skip or bypass the local limiter. Same 100 / 60s sliding window means a healthy Expo build never sees a server 429 under normal use; the server limit is for everyone else.
A store interface small enough to swap
We did not want middleware that knew about Redis on day one, and we did not want a rewrite when we eventually need a shared counter. The store is two methods:
// apps/web/app/api/v1/middleware/rate-limit.ts (excerpt)
/**
* Sliding-window store interface.
* Swap the default InMemoryStore for a Redis/KV implementation in production
* without changing the middleware logic.
*/
export interface RateLimitStore {
/** Record a hit for the given key. Returns the current request count in the window. */
hit(key: string, windowMs: number): number | Promise<number>;
/** Reset state (useful in tests). */
reset(): void | Promise<void>;
}The default implementation is a Map of timestamp arrays, pruned on every hit:
// apps/web/app/api/v1/middleware/rate-limit.ts (excerpt)
export class InMemoryStore implements RateLimitStore {
private readonly windows = new Map<string, number[]>();
hit(key: string, windowMs: number): number {
const now = Date.now();
const cutoff = now - windowMs;
const timestamps = (this.windows.get(key) ?? []).filter((t) => t > cutoff);
timestamps.push(now);
this.windows.set(key, timestamps);
return timestamps.length;
}
reset(): void {
this.windows.clear();
}
}Middleware is then just: derive key → hit store → maybe 429 → otherwise next():
// apps/web/app/api/v1/middleware/rate-limit.ts (excerpt)
return async (c, next) => {
const key = keyFn(c.req.raw);
const count = await store.hit(key, windowMs);
if (count > maxRequests) {
const body: ApiErrorBody = {
error: "Too Many Requests",
code: "RATE_LIMIT_EXCEEDED",
message: `Rate limit exceeded: ${maxRequests} requests per ${windowMs / 1000}s`,
};
return c.json(body, 429);
}
await next();
};That is the whole production surface. Tests inject a fresh InMemoryStore per case; a future Redis store only has to implement hit and reset.
Per-route budgets that must not share counters pass their own store. Reports are rarer than likes, so they get a tighter dedicated window:
// apps/web/app/api/v1/app.ts (excerpt)
// Reports get a tighter, dedicated limit (moderation actions should be rare
// compared to likes/reads). A dedicated InMemoryStore keeps its hit counts
// independent of the global "*" store above — sharing the default store
// would let report traffic eat into every other route's quota, and vice versa.
app.use(
"/reports/*",
rateLimitMiddleware({ maxRequests: 10, windowMs: 60_000, store: new InMemoryStore() })
);Forwarded IP: trust the edge, not the client
Default key derivation takes the first address in x-forwarded-for:
// apps/web/app/api/v1/middleware/rate-limit.ts (excerpt)
const keyFn =
options.keyFn ??
((req: Request) => {
const forwarded = req.headers.get("x-forwarded-for");
return forwarded ? forwarded.split(",")[0].trim() : "unknown";
});The middleware comment is blunt about the trust model: this relies on the CDN (Cloudflare in production) stripping client-supplied header prefixes. Without that edge, a caller can set x-forwarded-for to anything and either evade the limit or pin another IP's quota. We treat the header as an edge-provided identity, not a client claim — and we do not deploy this middleware naked on the public internet.
Missing header falls back to "unknown", which buckets those requests together. That is deliberate conservatism: prefer one shared bucket over inventing a per-request key that an attacker could rotate.
What an in-memory store will not do
InMemoryStore is a module-level singleton when callers omit store. That keeps the global * limit coherent inside one process, and the code warns that a future tighter per-route limit must pass its own store or it will share hit counts with everything else.
The honest trade-off: in a multi-instance or serverless deployment, each instance has its own Map. The effective ceiling is per instance, not cluster-wide. We accept that for now because:
- The primary goal is to stop cheap brute-force from reaching session lookup, not to enforce a hard global SLA.
- Cloudflare already sits in front and absorbs a lot of junk traffic.
- The Expo client already throttles itself at the same budget, so legitimate mobile traffic rarely needs a shared server counter.
- The store interface means we can add Redis/KV without rewriting middleware when a true shared quota becomes necessary.
So the design is a small, swappable throttle placed before auth — not a finished distributed rate-limit product. That is enough to make the expensive check rare for attackers, keep client and server speaking the same numbers, and leave a clean seam for the day we need a shared store.
Takeaways
- Put the cheap gate first. Rate limit before session lookup so brute-force never buys a DB round-trip.
- Align client and server defaults (100 / 60s, same error code) so healthy apps never see a server 429.
- Keep the store behind a two-method interface; in-memory is fine until you need cluster-wide counts.
- Key on forwarded IP only behind a trusted CDN; document that dependency.
- Give special routes their own store when their budget must not share with the global
*limit.
Related: Protected by Default: A Hono API That Fails Closed — the fail-closed auth boundary this limiter sits in front of.