Skip to content

Rate Limiting

This template does not ship with rate limiting. If you are deploying as a public-facing SaaS (or any environment where abuse is a concern), you can add a Postgres-backed fixed-window rate limiter with zero external dependencies.

The following prompt can be pasted directly into Claude Code to implement it end-to-end.

The prompt

Add a Postgres-backed fixed-window rate limiter to this project. Here is the
full specification:

### 1. Database migration

Create a Supabase migration that:

- Creates a `rate_limits` table:
  - `key` (text, not null) — arbitrary string identifying the resource being
    limited (e.g. "invite-create:<team_id>")
  - `window_start` (timestamptz, not null) — the start of the current time
    window
  - `count` (int, not null, default 0) — number of requests in this window
  - Primary key: `(key, window_start)`
- Adds an index on `window_start` for cleanup queries
- Enables RLS with zero policies (table is only accessed by the service role)
- Creates an `enforce_rate_limit(p_key text, p_window_seconds int)` function
  (SECURITY DEFINER, search_path = public, pg_temp) that:
  - Snaps to the current fixed window: `to_timestamp(floor(extract(epoch from
    now()) / p_window_seconds) * p_window_seconds)`
  - Upserts into `rate_limits` with `ON CONFLICT DO UPDATE SET count =
    rate_limits.count + 1`
  - Returns the new count
- Creates a `cleanup_rate_limits()` function that deletes rows older than 1 day

### 2. Server utility

Create `server/utils/rateLimit.ts` with an `enforceRateLimit` function:

```ts
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "~~/shared/types/database.types";
import { createError } from "h3";

type RateLimitOptions = {
  key: string;
  limit: number;
  windowSeconds: number;
};

export async function enforceRateLimit(
  client: SupabaseClient<Database>,
  { key, limit, windowSeconds }: RateLimitOptions,
) {
  const { data, error } = await client.rpc("enforce_rate_limit", {
    p_key: key,
    p_window_seconds: windowSeconds,
  });

  if (error || data == null) {
    console.error("[rateLimit] enforce_rate_limit failed:", error);
    return; // fail open
  }

  if (data > limit) {
    throw createError({
      statusCode: 429,
      statusMessage: "Too many requests. Please slow down and try again shortly.",
    });
  }
}
```

### 3. Add rate limits to these server routes

Each `enforceRateLimit` call goes right after the auth check, before any
business logic:

- `server/api/teams/index.post.ts` — 10 per hour per user:
  `{ key: \`team-create:${user.sub}\`, limit: 10, windowSeconds: 60 * 60 }`

- `server/api/teams/[teamId]/invitations/index.post.ts` — two limits:
  - 10 per minute per team:
    `{ key: \`invite-create:${teamId}:m\`, limit: 10, windowSeconds: 60 }`
  - 100 per hour per team:
    `{ key: \`invite-create:${teamId}:h\`, limit: 100, windowSeconds: 60 * 60 }`

- `server/api/invitations/[token].get.ts` — 30 per minute per IP (this is an
  unauthenticated endpoint, so key on IP instead of user):
  ```ts
  const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown";
  await enforceRateLimit(client, { key: \`invite-lookup:${ip}\`, limit: 30, windowSeconds: 60 });
  ```

- `server/api/invitations/[token]/accept.post.ts` — 20 per minute per user:
  `{ key: \`invite-accept:${user.sub}\`, limit: 20, windowSeconds: 60 }`

- `server/api/teams/[teamId]/avatar.post.ts` — 10 per minute per team:
  `{ key: \`team-avatar-upload:${teamMember.teamId}\`, limit: 10, windowSeconds: 60 }`

- `server/api/auth/profile/avatar.post.ts` — 10 per minute per user:
  `{ key: \`avatar-upload:${user.sub}\`, limit: 10, windowSeconds: 60 }`

### 4. Regenerate database types

Run `npx supabase gen types typescript` to add the `rate_limits` table and
the `enforce_rate_limit` / `cleanup_rate_limits` functions to
`shared/types/database.types.ts`.