← All posts
·8 min read·GuardLayer

Rate limiting a Next.js server action (Supabase)

Next.jsServer ActionsSupabaseRate LimitingSecurity

Next.js doesn't rate-limit Server Actions or Route Handlers. Every action is a public POST endpoint that a script can call as fast as it likes. Limit each action by client IP (or user ID) with a counter in a shared store like Redis, not an in-memory Map. On Supabase, also make sure the table can't be written with the publishable key, or attackers will skip your limiter and go straight to the database.

A waitlist form, a contact form, an "invite a teammate" button: the first public Server Action most apps ship is one that writes a row and sends an email. Nobody needs to log in to use it. That's the point of it, and it's also why it's the first thing a bot finds.

Does Next.js have built-in rate limiting?

No. Neither Server Actions nor Route Handlers come with a request limit, and next.config.js has no setting for one. Some hosts add platform-level controls (Vercel's firewall, Cloudflare's rate-limiting rules), but those live outside your code and need configuring. Out of the box, your action runs as many times as it's called.

A Server Action really is a public endpoint. A "use server" export is invoked by a POST to your page with a Next-Action header naming the action. Hiding the form doesn't hide the endpoint: anyone who loads the page once can copy the request out of devtools and replay it in a loop. The Server Action auth post covers the authorization side of that. This post covers the volume side.

What an unlimited public action costs you

Here's a typical waitlist action:

"use server";
import { Resend } from "resend";
import { createClient } from "@/lib/supabase/server";

const resend = new Resend(process.env.RESEND_API_KEY);

export async function joinWaitlist(formData: FormData) {
  const email = String(formData.get("email") ?? "");
  const supabase = await createClient();

  await supabase.from("waitlist").insert({ email });
  await resend.emails.send({
    from: "hello@yourapp.com",
    to: email,
    subject: "You're on the list",
    html: "<p>Thanks for signing up!</p>",
  });
}
guardlayer scan · app/actions/waitlist.tsLive engine output
Passed with warnings
92/100 · A
  • Warningapp/actions/waitlist.ts:11

    Server Action without auth check

    At the top of every Server Action, resolve and verify the current user (e.g. const { user } = await getUser(); if (!user) throw …) and authorize the specific operation before mutating data. If you do guard it with a custom helper, this warning is a false positive.

The scanner reports a write with no auth check. For a deliberately public form that's expected, and it's the honest way to read the finding: this is an unauthenticated write, so it needs another limit instead. Left unlimited, a loop against this action will:

  • Send email from your domain to any address. to: email comes from the request, so each call is a free email to a stranger, signed with your domain. Bounces and spam complaints pile up against your sending reputation, and your provider can suspend the account.
  • Burn through quotas. Email credits, function invocations and database rows all have a monthly ceiling on a small plan.
  • Fill the table with junk, which you then have to clean up by hand before launch.

How do I rate limit a Next.js server action?

Count requests per caller in a store every server instance shares, and refuse once the count passes a limit. Server Actions don't give you the raw Request, so read the IP from headers(). Here it is with @upstash/ratelimit, which works over HTTP and runs on serverless:

// lib/ratelimit.ts
import "server-only";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { headers } from "next/headers";

const limiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "10 m"), // 5 calls per 10 minutes
  prefix: "rl:waitlist",
});

export async function limitByIp() {
  const h = await headers();
  const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
  const { success } = await limiter.limit(ip);
  return success;
}
"use server";
import { limitByIp } from "@/lib/ratelimit";

export async function joinWaitlist(formData: FormData) {
  if (!(await limitByIp())) {
    return { error: "Too many requests. Try again in a few minutes." };
  }
  // ...validate the email, then insert and send as before
}

Four details decide whether this works in production:

  1. Not an in-memory Map. Serverless platforms spread traffic across many short-lived instances, and each has its own memory. A per-process counter resets on every cold start, and an attacker hitting several instances never reaches the limit.
  2. Make sure the IP header is set by something you trust. Vercel's request-header docs say it overwrites X-Forwarded-For and doesn't forward external IPs, to prevent spoofing. If you self-host without a proxy that sets the header, the client controls it, and putting a new fake IP on each request gets around your limit.
  3. Use the user ID when there is one. For logged-in actions, limiter.limit(user.id) is stronger than the IP: it doesn't penalize a shared office NAT, and a botnet can't rotate it.
  4. Return an error instead of throwing. A thrown error in an action surfaces as a generic failure. Returning { error } lets the form show a real message.

You can also run the check in proxy.ts (middleware.ts before Next.js 16), keyed on the Next-Action header or the route path. That's fine as a broad first layer. Keep the per-action check anyway, for the same reason auth checks belong in the action and not the proxy: it's the only check that can't be skipped by a matcher change.

The Supabase back door: your limiter only works if it's the only way in

This is the part most rate-limiting guides skip. If the waitlist table has an RLS policy like this:

create policy "anyone can join"
  on public.waitlist for insert
  to anon
  with check (true);

then the server action isn't the only way to write a row. Your publishable (anon) key is in the browser bundle by design, so anyone can call PostgREST directly:

curl -X POST "https://<project>.supabase.co/rest/v1/waitlist" \
  -H "apikey: <publishable key>" \
  -H "Content-Type: application/json" \
  -d '{"email":"junk@example.com"}'

That request never touches your Next.js app, so your limiter never runs. The fix is to make the action the only way to write:

  • Drop the anon insert policy and keep RLS enabled. With RLS on and no matching policy, PostgREST refuses anon writes.
  • Write from the server with a privileged client (the secret / service_role key, in a server-only module), now that the action is the gatekeeper and does the validation and limiting itself.

Never do the opposite and expose that privileged key to fix a permissions error. That's the service-role leak, which is far worse than spam. GuardLayer's supabase/policy-using-true rule flags the with check (true) policy above when it's in a checked-in migration.

Supabase Auth endpoints (sign-up, OTP, password reset) are different: they have their own server-side limits, listed in the Supabase auth rate-limit table. Your Next.js limiter doesn't replace those. It protects what you built on top.

What GuardLayer catches here, and what it doesn't

GuardLayer doesn't check whether a rate limiter exists. No static scan can reliably tell that a given request path is throttled. What it gives you is a partial inventory: "use server" modules that write data with no detectable auth check (nextjs/server-action-no-auth), Route Handlers that pass request input to a dangerous sink (nextjs/api-route-no-input-validation), and permissive insert policies in migrations. It doesn't flag a Route Handler that writes named fields from the body, like insert({ email: body.email }), or an action or route that only sends email. Add those yourself, and that list is your rate-limit to-do list: anything public that writes data or sends email needs a limit.

Quick self-check

# 1. Every server action module
grep -rln '"use server"' app/ lib/

# 2. Which of them already call a limiter?
grep -rln "ratelimit\|rateLimit\|limiter" app/ lib/

# 3. Insert policies open to anon in migrations
grep -rniE "for insert.*to anon|to anon.*with check \(true\)" supabase/migrations/

Then try it for real: submit the public form 20 times in a row, and send the curl above against each public table. If either goes through every time, you have no limit.

FAQ

Can I rate limit in Next.js without Redis? On a single long-lived Node server, an in-memory store works. On serverless or with several instances it doesn't, because the counters aren't shared. Any shared store works: Redis, Upstash, or a Postgres table with an atomic upsert.

Should I rate limit by IP or by user? By user ID when the caller is logged in, by IP when they're not. IP limits are coarse: mobile carriers and offices put many people behind one address, so set the threshold generously.

Does a CAPTCHA replace rate limiting? No, they do different jobs. A CAPTCHA (Cloudflare Turnstile, hCaptcha) makes each request cost the bot something, and a rate limit caps how many get through. Public forms that send email benefit from both.

Is a Server Action's endpoint secret since its ID is encrypted? No. The ID is in the page your visitors load, and replaying a captured request works. Treat every action as a public endpoint.

Does Supabase rate limit database inserts from the anon key? Don't count on it. Supabase publishes limits for its Auth endpoints, and those don't cover your tables. Writes through the publishable key are governed by your RLS policies, so close that path instead of hoping something upstream throttles it.

Catch this before it ships — free

Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.

No signup, no card. Raw files aren’t kept; only the report is saved.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.