Skip to main content
Public API routes without rate limits get scraped, brute-forced, and overwhelmed within days of launch. Login endpoints get credential-stuffing attacks. Signup forms get spam-bot flooded. SMS-sending routes get exploited for free message delivery. Rate limiting is the simplest defense.
Common endpoints to rate-limit: login, signup, password reset, public search, contact form, SMS sending, any expensive AI or external-API call.

Three Paths

Fastest to write, zero dependencies. Only works per-container — state is lost on cold start or if you have multiple instances. Fine for quick protection on low-traffic sites; not sufficient at scale.

Redis Pattern with Upstash

1

Sign up at Upstash

Go to upstash.com, create a Redis database. The free tier handles 10k commands/day — plenty for moderate-traffic sites.
2

Copy REST credentials

From the database page, copy:
  • UPSTASH_REDIS_REST_URL
  • UPSTASH_REDIS_REST_TOKEN
3

Add env vars in Hiveku

Settings > Environment Variables. Add both. See Environment Variables.
4

Install the SDK

npm install @upstash/ratelimit @upstash/redis
5

Wire up an API route

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '60 s'),
});

export default async function handler(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';
  const { success, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return Response.json(
      { error: 'Too many requests' },
      {
        status: 429,
        headers: { 'Retry-After': String(reset) },
      }
    );
  }

  // Your actual handler logic
  return Response.json({ ok: true });
}

Common Limits by Endpoint

EndpointSuggested limit
Login5 attempts per minute per IP
Signup3 per minute per IP
Password reset3 per hour per email
Public search30 per minute per IP
Contact form5 per hour per IP
Send SMS10 per day per phone number
AI chat / expensive API100 per day per user
These are starting points — tune based on your actual traffic patterns.

Choosing the Rate-Limit Key

What you limit by matters as much as the number:
  • By IP. Simple. Blocks coffee shops and corporate NATs if abused (false positives). Good for pre-auth endpoints where you have no better identifier.
  • By user ID. Targeted. Doesn’t help for pre-auth endpoints like login or signup.
  • By email or phone number. Good for signup/login/reset — stops account-creation farming where an attacker rotates IPs but reuses the same email pattern.
  • Composite keys. For login: limit by IP AND by email. An attacker hitting one email from many IPs triggers the email limit; one IP hitting many emails triggers the IP limit. Both must be low to get through.

Return Proper 429 Response Headers

Clients can behave correctly if you tell them the limit state:
return Response.json(
  { error: 'Too many requests' },
  {
    status: 429,
    headers: {
      'Retry-After': String(reset),
      'X-RateLimit-Limit': '10',
      'X-RateLimit-Remaining': String(remaining),
      'X-RateLimit-Reset': String(reset),
    },
  }
);
Retry-After is the most important — well-behaved clients will back off for that many seconds.

Client-Side Handling

When you get a 429:
1

Show a friendly error

Not “Error 429”. Something like “You’re doing that too fast. Try again in 30 seconds.”
2

Show a countdown

Display a countdown timer based on Retry-After. Disable the submit button until it elapses.
3

Exponential backoff on retries

If your client auto-retries, double the delay each time: 1s, 2s, 4s, 8s. Cap at a sensible max.

Database-Backed Pattern

If Redis isn’t an option, a minimal DB approach:
CREATE TABLE rate_limits (
  key text NOT NULL,
  window_start timestamptz NOT NULL,
  count integer DEFAULT 1,
  PRIMARY KEY (key, window_start)
);

CREATE INDEX ON rate_limits (window_start);
On each request:
const window = new Date(Math.floor(Date.now() / 60000) * 60000);
const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';

const result = await db.query(
  `INSERT INTO rate_limits (key, window_start, count)
   VALUES ($1, $2, 1)
   ON CONFLICT (key, window_start)
   DO UPDATE SET count = rate_limits.count + 1
   RETURNING count`,
  [ip, window]
);

if (result[0].count > 10) {
  return new Response('Too many requests', { status: 429 });
}
Clean up old rows periodically with a scheduled workflow: DELETE FROM rate_limits WHERE window_start < now() - interval '1 hour'.

What NOT to Rate-Limit

Don’t rate-limit endpoints a legitimate single user needs to hit often — autocomplete suggestions, incremental search, drag-and-drop save calls. Rate limits should prevent abuse, not break UX.
Good candidates: per-IP + per-user composite limits that are high enough never to hit in normal usage but low enough to block scripts.

Verify It Worked

1

Hit the endpoint in a loop

for i in {1..15}; do
  curl -i https://yoursite.com/api/login \
    -H "Content-Type: application/json" \
    -d '{"email":"test@test.com","password":"wrong"}'
  echo ""
done
2

Confirm 429 after the limit

The first N requests should succeed (or return the usual login error). Request N+1 should return 429 Too Many Requests.
3

Wait and retry

Wait for the Retry-After duration. The endpoint should accept requests again.

Troubleshooting

Check env vars. If UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN is missing, the SDK silently fails closed or open depending on config. Log the result of ratelimit.limit(...) to confirm it’s actually being hit.
Your limits are too aggressive. Check your analytics for typical request rates from real users and set limits comfortably above the 99th percentile. Also consider switching from by-IP to by-user ID for authenticated endpoints.
You’re using in-memory limits. They don’t survive cold starts or new deployments. Switch to Redis or DB-backed limits.
x-forwarded-for may contain a chain of IPs. Use the first one (original client), not the last. Also check your reverse proxy/CDN is adding this header correctly and not stripping it.
Your WAF and app-level rate limits are double-counting. Either disable WAF rate limiting for these routes and handle it all at the app, or the opposite. Don’t stack — it confuses users and makes debugging hard.

What’s Next?

Environment Variables

Manage Redis and other service credentials safely

View Logs

Debug rate limiting issues in production