H Heygents Docs Open App

Updated July 31, 2026 · Security · High severity

Public endpoints have no rate limiting

why are my login and signup endpoints not rate limited Your login, signup, and contact endpoints almost certainly have no throttle unless you added one deliberately. Frameworks ship with none by default. The fix is per-IP and per-account limits backed by Redis or a similar shared store, returning 429 with Retry-After. Confirm by hammering the endpoint with curl in a loop and watching every request return 200.

Seen in 21 of 450 scanned projects (5%). Based on 450 Deep Scan runs across production projects.

Common stacks: Node.js Redis Express Next.js Postgres

How to tell you have it

  • Login endpoint accepts unlimited attempts from one IP with no lockout
  • Password reset and signup emails can be triggered in a loop, exhausting the email provider quota
  • Contact and subscribe forms get spammed by bots with no throttling
  • A rate limiter exists but resets on every deploy or pm2 restart
  • LLM-backed endpoints have no per-user cap and a single script can run up the API bill

Why it matters

An unthrottled login endpoint is a credential stuffing machine. Attackers already have billions of leaked username and password pairs from other breaches, and they run them against every login form they can find. Without a limit, your server will happily process tens of thousands of guesses per minute from a single IP or a rotating botnet, and some of those guesses will succeed because users reuse passwords.

Endpoints that trigger a side effect, an email, an SMS, a paid LLM call, cost you money or reputation per request even when the attacker never gets in. A subscribe or password-reset endpoint with no cap can be looped to blow through your transactional email quota in minutes, which then gets your sending domain throttled or blacklisted by the provider, which breaks email for real users too.

In-memory rate limiters, the kind implemented as a plain object or Map inside the Node process, look like they work in local testing and then quietly fail in production. They reset every time the process restarts, which happens on every deploy, every crash, every pm2 reload. If you run more than one instance behind a load balancer, each instance keeps its own counter, so the effective limit is multiplied by instance count and an attacker just needs to send enough traffic to spread across them.

Backing a rate limiter with the primary Postgres database is its own hazard. Every request now issues a write to the same table, under load that write path becomes a hot spot, and during exactly the kind of traffic spike you were trying to defend against, you have added extra load to the database that everything else depends on.

How Heygents detects it

Deep Scan reads the route handlers for auth, contact, subscribe, confirm and unsubscribe endpoints looking for rate-limit middleware, checks whether a Redis or similar cache service is already running via docker/pm2 process lists, and runs curl in a bounded loop against the live login and contact endpoints to confirm whether repeated requests all return 200 with no 429.

How to fix it

  1. Confirm the gap with a bounded curl loop Before writing any code, prove the endpoint has no limit. Run a small number of requests against the login endpoint and confirm every response is 200 or 401, never 429.
  2. Add a shared-store rate limiter, not an in-memory one If Redis is already running, use it as the backing store so limits survive restarts and are shared across every instance behind the load balancer.
  3. Layer per-account limits on top of per-IP limits Per-IP alone is not enough because attackers spread attempts across many IPs. Add a second key on the account identifier so a single targeted account can only be tried a handful of times regardless of source IP.
  4. Return proper 429 semantics When the limiter trips, respond with status 429 and a Retry-After header so well-behaved clients and API consumers back off instead of retrying immediately.
  5. Cap cost-bearing endpoints per user, not just per IP For any endpoint that calls a paid LLM API or sends an email or SMS, add a daily or hourly per-account quota independent of the burst rate limit, so a compromised token or a scripting mistake cannot run up an unbounded bill.

Confirm the gap with a bounded curl loop

for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" -X POST https://example.com/api/login -d 'email=test@example.com&password=wrong'; done

Add a shared-store rate limiter, not an in-memory one

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

const redis = new Redis(process.env.REDIS_URL);
const limiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '1 m'),
});

export async function loginRateLimit(ip) {
  const { success, reset } = await limiter.limit(`login:${ip}`);
  if (!success) {
    const err = new Error('rate_limited');
    err.retryAfter = Math.ceil((reset - Date.now()) / 1000);
    throw err;
  }
}

Layer per-account limits on top of per-IP limits

await limiter.limit(`login:ip:${ip}`);
await limiter.limit(`login:acct:${normalizedEmail}`);

Return proper 429 semantics

res.status(429).set('Retry-After', String(retryAfter)).json({ error: 'Too many requests, try again later' });

Find this in your own projects, automatically

Heygents runs a read-only Deep Scan across every project you own, finds issues like this one, and hands you a ready-to-run fix an AI agent can execute and verify. A solo developer gets the audit, the backlog and the fix loop in one place.

Open Heygents →

Frequently asked questions

Do I need rate limiting if I already have a WAF or CDN in front of the app?

A WAF helps with volumetric abuse but rarely understands application semantics like login attempts per account. You still need application-level per-IP and per-account limiting on sensitive endpoints, because a low-and-slow credential stuffing attack can stay well under any CDN-level threshold.

Is an in-memory rate limiter ever acceptable?

Only for a single-instance hobby project with no uptime requirements. The moment you run more than one process or restart on deploy, an in-memory limiter stops providing real protection, so treat it as a placeholder, not a production control. To verify, trip the limit until you get a 429, restart the process, and confirm the very next request is blocked rather than allowed.

What limit values should I start with for login?

A common starting point is 5 attempts per IP per minute and 10 attempts per account per hour, then tune based on real traffic. Log rate-limit trips for a week before tightening further so you do not lock out legitimate users on shared IPs like offices.

Should rate limiting live in the database instead of Redis?

Avoid it. Every checked request becomes a write against your primary Postgres instance, adding load exactly during the traffic spikes you are trying to survive. A dedicated in-memory store like Redis is built for this access pattern and is far cheaper per operation.