fix(auth): rate limit Better Auth routes per client

This commit is contained in:
2026-07-10 11:27:52 -04:00
parent 73f52cdcb1
commit 91e0e4522f
10 changed files with 276 additions and 2 deletions
+43
View File
@@ -6,6 +6,7 @@ import {
authenticatedRateLimitKey,
readAuthContext,
} from "./auth.js";
import { requiresEmailDelivery } from "./better_auth.js";
import { jsonResponse } from "./responses.js";
const RATE_LIMIT_WINDOW_SECONDS = 60;
@@ -60,6 +61,43 @@ export async function withPublicApiControls(
}
}
/// Better Auth routes are unauthenticated, so they limit per client
/// address. Email-delivery routes consume the tight email limiter; every
/// other auth route shares the standard limiter.
export async function withAuthRouteControls(
request: Request,
env: Env,
handler: ApiHandler,
): Promise<Response> {
const emailDelivery = requiresEmailDelivery(request);
const route = emailDelivery ? "auth.email" : "auth.route";
const allowedMethods = ["GET", "POST"];
if (!allowedMethods.includes(request.method)) {
const response = methodNotAllowedResponse(allowedMethods);
recordApiAuditEvent(request, env, route, response, "method_not_allowed");
return response;
}
const limiter = emailDelivery ? env.ELY_AUTH_EMAIL_RATE_LIMITER : env.ELY_RATE_LIMITER;
const limit = await limiter.limit({
key: clientRateLimitKey(env.ELY_ENVIRONMENT, route, request),
});
if (!limit.success) {
const response = rateLimitedResponse();
recordApiAuditEvent(request, env, route, response, "rate_limited");
return response;
}
try {
const response = await handler();
recordApiAuditEvent(request, env, route, response, "handled");
return response;
} catch (error) {
recordApiAuditEvent(request, env, route, internalErrorResponse(), "exception");
throw error;
}
}
export async function withAuthenticatedApiControls(
request: Request,
env: Env,
@@ -148,6 +186,11 @@ function rateLimitKey(environment: string, route: string): string {
return `${environment}:${route}`;
}
function clientRateLimitKey(environment: string, route: string, request: Request): string {
const client = request.headers.get("cf-connecting-ip") ?? "unknown";
return `${environment}:${route}:${client}`;
}
function recordApiAuditEvent(
request: Request,
env: Env,
+1 -1
View File
@@ -40,7 +40,7 @@ export async function handleBetterAuthRoute(request: Request, env: Env): Promise
}
}
function requiresEmailDelivery(request: Request): boolean {
export function requiresEmailDelivery(request: Request): boolean {
if (request.method !== "POST") {
return false;
}
+1
View File
@@ -93,6 +93,7 @@ export interface Env {
ELY_KV: ElyKvNamespace;
ELY_STORAGE: ElyR2Bucket;
ELY_RATE_LIMITER: ElyRateLimit;
ELY_AUTH_EMAIL_RATE_LIMITER: ElyRateLimit;
ELY_API_AUDIT: ElyAnalyticsDataset;
ELY_DIAGNOSTICS: ElyAnalyticsDataset;
ELY_ENVIRONMENT: string;
+2 -1
View File
@@ -4,6 +4,7 @@ import {
deleteAuthenticatedSession,
} from "./auth.js";
import {
withAuthRouteControls,
withAuthenticatedApiControls,
withPublicApiControls,
} from "./api_controls.js";
@@ -61,7 +62,7 @@ export default {
export async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/auth" || url.pathname.startsWith("/api/auth/")) {
return handleBetterAuthRoute(request, env);
return withAuthRouteControls(request, env, () => handleBetterAuthRoute(request, env));
}
if (url.pathname === "/api/session/logout") {
return withAuthenticatedApiControls(