SecureStartKit applies rate limiting to its built-in auth actions, but you must add it to every new sensitive or expensive action you create.
Last reviewed June 13, 2026 by SecureStartKit Team
The short answer
Server Actions are plain POST endpoints. Apply an Upstash Ratelimit sliding-window limiter keyed by IP (or user id) at the top of every sensitive or expensive action, and return an error early if the limit is exceeded. In serverless, use a durable store so the counter survives across instances.
Where it shows up: A sensitive or expensive Server Action (password reset, magic-link send, AI call, Stripe Checkout creation) runs with no per-IP or per-user rate limit in front of it.
// actions/auth.ts
'use server'
import { resend } from '@/lib/resend'
export async function requestPasswordReset(email: string) {
const user = await getUserByEmail(email)
if (!user) {
// Different branch leaks whether the account exists
return { error: 'No account found for that email.' }
}
const token = await createResetToken(user.id)
await resend.emails.send({
from: 'noreply@example.com',
to: email,
subject: 'Reset your password',
html: '<a href="https://example.com/reset?token=' + token + '">Reset</a>',
})
return { success: true }
}No rate limit means an attacker can call this thousands of times per minute, flooding the target inbox and burning Resend credits. The distinct error on the not-found branch also leaks account existence.
// actions/auth.ts
'use server'
import { headers } from 'next/headers'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { resend } from '@/lib/resend'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '15 m'),
prefix: 'rl:password-reset',
})
export async function requestPasswordReset(email: string) {
const ip = (await headers()).get('x-forwarded-for') ?? 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) {
return { error: 'Too many requests. Please wait before trying again.' }
}
const user = await getUserByEmail(email)
// Constant response prevents account enumeration
if (!user) return { success: true }
const token = await createResetToken(user.id)
await resend.emails.send({
from: 'noreply@example.com',
to: email,
subject: 'Reset your password',
html: '<a href="https://example.com/reset?token=' + token + '">Reset</a>',
})
return { success: true }
}A sliding-window limiter (5 requests per 15 minutes) keyed by IP stops email bombing. The response is now identical on both branches, removing the account-enumeration leak.
// actions/ai.ts
'use server'
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { auth } from '@/lib/auth'
export async function generateReport(prompt: string) {
const session = await auth()
if (!session) return { error: 'Unauthenticated' }
// No cap: each call costs real money
const { text } = await generateText({ model: openai('gpt-4o'), prompt })
return { text }
}Authentication is present but there is no call-rate cap per user. A single logged-in user (or a stolen session) can fire this in a loop and run up a large model bill before the next billing alert.
// actions/ai.ts
'use server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { auth } from '@/lib/auth'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 h'),
prefix: 'rl:ai-report',
})
export async function generateReport(prompt: string) {
const session = await auth()
if (!session) return { error: 'Unauthenticated' }
// Keyed by user id so the cap is per-account, not per-IP
const { success, remaining } = await ratelimit.limit(session.user.id)
if (!success) {
return { error: 'Hourly report limit reached. Try again later.' }
}
const { text } = await generateText({ model: openai('gpt-4o'), prompt })
return { text, remaining }
}Keying by user id caps each account at 10 generations per hour regardless of IP, preventing bill-running abuse even from authenticated sessions. The remaining count feeds a live UI counter.
The attacker opens DevTools, submits the forgot-password form once, and copies the raw POST request. Server Actions are called via fetch to the same URL path with a special header. No auth is required to call them.
A simple loop then fires the action hundreds of times per minute. For a password-reset action this causes three distinct harms: the target inbox is flooded (email bombing), each failed attempt reveals whether the address is registered (account enumeration through differing error messages), and your Resend bill accumulates with real charges per email.
For actions that create Stripe Checkout sessions or invoke an LLM, each call costs money. An attacker who finds that your generate-report action has no guard can run up hundreds of dollars in API fees before you notice. Serverless makes this worse: every invocation is stateless, so a naive in-memory counter resets on every cold start and provides no real protection.
If the action returns a short-lived OTP or validates a token, an unlimited call rate turns a one-in-a-million guess into a practical attack within minutes.
List every Server Action by searching for the use server directive:
grep -rl "use server" ./actions ./app
For each file, check whether it imports from @upstash/ratelimit (or an equivalent durable store) before the main logic. Any file that sends email, creates a Stripe session, calls an LLM, or validates a credential without a rate-limit import is a gap.
Also check route handlers under app/api: any handler that does not call a rate limiter before its main logic is unguarded.
In serverless environments (Vercel, AWS Lambda), never rely on a module-level Map or in-memory counter. Those reset on every cold start and offer no real protection. A Redis-backed store such as Upstash is the minimum viable durable counter.
Myth“In-memory counters work fine for rate limiting in a serverless Next.js app.”
Each serverless instance is stateless. The counter resets on every cold start and is not shared across concurrent instances, so an attacker hits multiple instances in parallel to bypass it entirely.
Myth“Rate limiting by IP is enough for logged-in users.”
For actions tied to a specific account (AI generation, premium features, billing), key the limiter by user id. IP limits are bypassed by rotating a proxy pool and they punish users behind shared NAT.
Myth“Server Actions are not real API endpoints, so they are harder to abuse than REST routes.”
Server Actions compile to plain POST requests. The action header and form encoding are easy to replicate with curl. Assume every Server Action is as exposed as a public REST endpoint.
Myth“A CAPTCHA replaces the need for a server-side rate limit.”
CAPTCHAs are a UX control, not a server-side enforcement point. They are bypassed by solving services and have no effect on direct API calls that skip the browser form. Use both: CAPTCHA for friction, rate limit for enforcement.
The kit ships a rate-limit helper (lib/rate-limit.ts) and applies it to its auth actions. That helper is a simple in-memory limiter, which is fine for a single instance or light traffic but resets on every cold start in serverless. For serverless or higher traffic, swap it for a durable store such as Upstash Redis, as the secure examples show. Either way, actions you add for AI features, billing, or custom email sends start unguarded: copy the pattern into each new action, and fail closed if the limiter itself errors.
Rate limiting in the kit