Skip to content

feat(auth): per-IP signup rate limit with Redis-backed storage#4828

Open
waleedlatif1 wants to merge 1 commit into
stagingfrom
waleedlatif1/signup-ip-rate-limit
Open

feat(auth): per-IP signup rate limit with Redis-backed storage#4828
waleedlatif1 wants to merge 1 commit into
stagingfrom
waleedlatif1/signup-ip-rate-limit

Conversation

@waleedlatif1
Copy link
Copy Markdown
Collaborator

What

Adds a long-window per-IP rate limit on email signup, backed by Redis so it holds across replicas and deploys.

Why

better-auth's built-in rate limit (already active in prod) only guards bursts — its default special rule caps /sign-up* at 3 requests / 10s per IP. A signup-spam botnet that drips one request every few seconds from a single IP never trips that window, which is how the recent wave got through despite Turnstile + MX/domain denylists. There was no sustained per-IP cap.

What changed

  • /sign-up/email custom rule: default 20 signups / hour / IP, configurable via SIGNUP_RATE_LIMIT_MAX and SIGNUP_RATE_LIMIT_WINDOW_SECONDS. Deliberately generous — an enterprise office behind a single NAT stays well under it, and SSO / org-invite onboarding use different endpoints this rule does not touch.
  • Redis-backed storage (rateLimit.customStorage) when REDIS_URL is set, so per-IP counts are consistent across ECS replicas and survive deploys. Without Redis it falls back to better-auth's in-memory store (unchanged for self-hosters). Session storage is untouched — we do not set secondaryStorage, so this is isolated to rate limiting and adds no load to the Postgres primary.
  • Fail-open: any Redis error makes get return "no prior requests" and set a no-op, so a Redis outage degrades to allow rather than locking users out of auth.
  • advanced.ipAddress.ipAddressHeaders: prefer cf-connecting-ip (set by Cloudflare's edge, not forgeable by the caller) so the rate-limit key is the real client IP; fall back to x-forwarded-for for non-Cloudflare deployments. This also closes a bypass where a caller could forge x-forwarded-for to mint a fresh key per request.

Safety / non-goals

  • enabled mirrors better-auth's default (production only) so local/CI signup stays unthrottled.
  • No DB migration. No schema change. No change to session handling.
  • Rate limit is defense-in-depth on top of Turnstile + MX validation + domain denylist — not a replacement.

Tuning

Env Default Notes
SIGNUP_RATE_LIMIT_MAX 20 Max email signups per IP per window
SIGNUP_RATE_LIMIT_WINDOW_SECONDS 3600 Window length (1h)

If an enterprise reports being blocked, raise SIGNUP_RATE_LIMIT_MAX (no deploy needed beyond the env change).

Tests

lib/auth/rate-limit-storage.test.ts — Redis read/parse, empty read, TTL-bounded write, fail-open on read/write errors, and undefined (memory fallback) when Redis is absent.

🤖 Generated with Claude Code

better-auth's built-in rate limit only guards bursts (3 req / 10s on
/sign-up*), which a slow drip of signups from a single IP sails past. Add a
long-window per-IP cap on /sign-up/email (default 20 / hour, configurable via
SIGNUP_RATE_LIMIT_MAX and SIGNUP_RATE_LIMIT_WINDOW_SECONDS) to bound sustained
abuse without affecting legitimate use — an enterprise office behind one NAT
stays well under it, and SSO / org-invite onboarding use different endpoints
this rule does not touch.

Back the limiter with Redis when REDIS_URL is set, via rateLimit.customStorage,
so counts hold across replicas and survive deploys. Without Redis it falls back
to better-auth's in-memory store (unchanged behavior for self-hosters). Reads
and writes fail open: a Redis outage degrades to "allow" rather than locking
users out of auth. Session storage is untouched (we do not set secondaryStorage).

Set advanced.ipAddress.ipAddressHeaders to prefer cf-connecting-ip so the
rate-limit key is the real client IP (set by Cloudflare's edge, not forgeable by
the caller), falling back to x-forwarded-for for non-Cloudflare deployments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 1, 2026 5:05pm

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented Jun 1, 2026

PR Summary

Medium Risk
Changes the signup auth path, IP derivation for rate limits, and fail-open Redis behavior—security-sensitive but scoped to /sign-up/email with generous defaults and no session/DB changes.

Overview
Adds a sustained per-IP cap on /sign-up/email (default 20 signups / hour / IP, tunable via SIGNUP_RATE_LIMIT_MAX and SIGNUP_RATE_LIMIT_WINDOW_SECONDS) on top of better-auth’s short burst limits. Rate limiting is enabled only in production; dev/CI signup stays unchanged.

When REDIS_URL is set, counters use new buildRedisRateLimitStorage (auth-rl:* keys, 1h TTL) as better-auth customStorage so limits apply across replicas and deploys; without Redis, behavior falls back to in-memory storage. Storage fails open on Redis errors so auth is not blocked.

advanced.ipAddress.ipAddressHeaders now prefers cf-connecting-ip, then x-forwarded-for, so per-IP keys reflect the real client behind Cloudflare and are harder to bypass by forging headers.

Vitest coverage was added for the Redis storage adapter; env schema documents the two new signup limit variables.

Reviewed by Cursor Bugbot for commit 75c2c6d. Configure here.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 75c2c6d. Configure here.

try {
const redis = getRedisClient()
if (!redis) return
await redis.set(`${REDIS_KEY_PREFIX}${key}`, JSON.stringify(value), 'EX', REDIS_TTL_SECONDS)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis TTL ignores signup window

Medium Severity

REDIS_TTL_SECONDS is fixed at 3600 while /sign-up/email uses SIGNUP_RATE_LIMIT_WINDOW_SECONDS, which can be longer. When a Redis key expires before that window ends, get returns nothing and the per-IP counter resets, so the sustained signup cap is weaker than configured.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75c2c6d. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 1, 2026

Greptile Summary

This PR adds a Redis-backed per-IP rate limit (default 20 signups / hour) on the /sign-up/email endpoint to close the gap where better-auth's existing burst limiter (3 req / 10 s) could be evaded by slow-drip bot traffic. It also configures cf-connecting-ip as the preferred IP header to close an x-forwarded-for spoofing bypass.

  • rate-limit-storage.ts: New Redis storage adapter with fail-open error handling; buildRedisRateLimitStorage() returns undefined (in-memory fallback) when Redis is absent, which is the right default for self-hosters.
  • auth.ts: Wires the custom storage and /sign-up/email custom rule into better-auth's rateLimit config; configures ipAddressHeaders to prefer Cloudflare's tamper-proof header.
  • env.ts: Two new optional env vars (SIGNUP_RATE_LIMIT_WINDOW_SECONDS, SIGNUP_RATE_LIMIT_MAX) with sensible defaults and inline docs.

Confidence Score: 3/5

Safe to merge for the default 1-hour window, but operators who configure a longer window via SIGNUP_RATE_LIMIT_WINDOW_SECONDS will get a weaker rate limit than intended due to a TTL mismatch.

The Redis TTL is hardcoded to 3600 s in rate-limit-storage.ts while the window is operator-configurable. Any deployment that increases SIGNUP_RATE_LIMIT_WINDOW_SECONDS beyond 3600 will see keys expire before the window closes, silently resetting the counter mid-window and allowing far more signups than the configured cap. The fail-open design means a Redis outage also completely bypasses the new limit, which is intentional but means this control is only reliable when Redis is healthy.

apps/sim/lib/auth/rate-limit-storage.ts — the hardcoded REDIS_TTL_SECONDS constant and the lack of a ttlSeconds parameter are the primary concern.

Important Files Changed

Filename Overview
apps/sim/lib/auth/rate-limit-storage.ts New Redis-backed storage adapter for better-auth's rate limiter; fail-open design is correct, but REDIS_TTL_SECONDS is hardcoded to 3600 and cannot adjust when the configurable window exceeds that value.
apps/sim/lib/auth/auth.ts Adds rateLimit customRules for /sign-up/email, Redis customStorage, and ipAddressHeaders; the 3600s Redis TTL in rate-limit-storage.ts won't cover operators who set SIGNUP_RATE_LIMIT_WINDOW_SECONDS > 3600.
apps/sim/lib/auth/rate-limit-storage.test.ts Good unit tests for all fail-open paths; TTL assertion hardcodes 3600 rather than testing that the configured window is respected.
apps/sim/lib/core/config/env.ts Adds two optional env vars with appropriate types and inline docs; no issues.

Sequence Diagram

sequenceDiagram
    participant Client
    participant BetterAuth
    participant RedisStorage as Redis Storage
    participant Redis
    participant AuthHandler

    Client->>BetterAuth: POST /sign-up/email
    BetterAuth->>BetterAuth: Resolve client IP (cf-connecting-ip preferred)
    BetterAuth->>RedisStorage: get(auth-rl:IP)
    RedisStorage->>Redis: GET auth-rl:IP
    alt Redis available
        Redis-->>RedisStorage: JSON counter or null
        RedisStorage-->>BetterAuth: RateLimitRecord or undefined
    else Redis error (fail-open)
        Redis-->>RedisStorage: Error
        RedisStorage-->>BetterAuth: undefined
    end
    BetterAuth->>BetterAuth: Check count vs max using lastRequest timestamp
    alt Under limit
        BetterAuth->>RedisStorage: set(auth-rl:IP, count+1)
        RedisStorage->>Redis: SET auth-rl:IP ... EX 3600
        BetterAuth->>AuthHandler: Proceed with signup
        AuthHandler-->>Client: 200 OK
    else Over limit
        BetterAuth-->>Client: 429 Too Many Requests
    end
Loading

Reviews (1): Last reviewed commit: "feat(auth): add per-IP signup rate limit..." | Re-trigger Greptile

Comment on lines +22 to +44
/**
* TTL for stored counters. Correctness comes from the `lastRequest` timestamp
* comparison in better-auth's limiter, not from expiry — the TTL only bounds
* key growth. It must be at least as long as the largest configured window so a
* key never expires mid-window and under-counts.
*/
const REDIS_TTL_SECONDS = 3600

/**
* Redis-backed storage for better-auth's rate limiter.
*
* Returns `undefined` when no Redis is configured, in which case better-auth
* falls back to its default in-memory store. That store is per-process and
* resets on deploy, so a long-window per-IP cap only holds across a multi-replica
* deployment when counts live in shared storage. Backing the limiter with Redis
* (rather than the primary database) keeps the per-request counter I/O off the
* Postgres primary.
*
* Fail-open: any Redis error resolves `get` to `undefined` (treated as no prior
* requests) and makes `set` a no-op, so a Redis outage degrades to "allow"
* rather than locking users out of authentication.
*/
export function buildRedisRateLimitStorage(): RateLimitStorage | undefined {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The TTL is hardcoded to 3600 but the rate-limit window is operator-configurable via SIGNUP_RATE_LIMIT_WINDOW_SECONDS. The code comment correctly states "it must be at least as long as the largest configured window so a key never expires mid-window and under-counts" — but the hardcoded value violates that guarantee for any window > 3600 s. If an operator sets SIGNUP_RATE_LIMIT_WINDOW_SECONDS=7200, keys expire after one hour, the counter resets mid-window, and the IP gets a fresh 20-signup budget in the second hour. Accepting ttlSeconds as a parameter (with a 3600 default) and threading signupRateLimitWindowSeconds from auth.ts into the call site fixes this.

Suggested change
/**
* TTL for stored counters. Correctness comes from the `lastRequest` timestamp
* comparison in better-auth's limiter, not from expiry — the TTL only bounds
* key growth. It must be at least as long as the largest configured window so a
* key never expires mid-window and under-counts.
*/
const REDIS_TTL_SECONDS = 3600
/**
* Redis-backed storage for better-auth's rate limiter.
*
* Returns `undefined` when no Redis is configured, in which case better-auth
* falls back to its default in-memory store. That store is per-process and
* resets on deploy, so a long-window per-IP cap only holds across a multi-replica
* deployment when counts live in shared storage. Backing the limiter with Redis
* (rather than the primary database) keeps the per-request counter I/O off the
* Postgres primary.
*
* Fail-open: any Redis error resolves `get` to `undefined` (treated as no prior
* requests) and makes `set` a no-op, so a Redis outage degrades to "allow"
* rather than locking users out of authentication.
*/
export function buildRedisRateLimitStorage(): RateLimitStorage | undefined {
/**
* Redis-backed storage for better-auth's rate limiter.
*
* Returns `undefined` when no Redis is configured, in which case better-auth
* falls back to its default in-memory store. That store is per-process and
* resets on deploy, so a long-window per-IP cap only holds across a multi-replica
* deployment when counts live in shared storage. Backing the limiter with Redis
* (rather than the primary database) keeps the per-request counter I/O off the
* Postgres primary.
*
* Fail-open: any Redis error resolves `get` to `undefined` (treated as no prior
* requests) and makes `set` a no-op, so a Redis outage degrades to "allow"
* rather than locking users out of authentication.
*
* @param ttlSeconds Redis key TTL. Must be the largest configured rate-limit
* window so keys never expire mid-window and under-count. Defaults to 3600 s.
*/
export function buildRedisRateLimitStorage(ttlSeconds = 3600): RateLimitStorage | undefined {

try {
const redis = getRedisClient()
if (!redis) return
await redis.set(`${REDIS_KEY_PREFIX}${key}`, JSON.stringify(value), 'EX', REDIS_TTL_SECONDS)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Update the set implementation to use the ttlSeconds parameter instead of the now-removed module constant.

Suggested change
await redis.set(`${REDIS_KEY_PREFIX}${key}`, JSON.stringify(value), 'EX', REDIS_TTL_SECONDS)
await redis.set(`${REDIS_KEY_PREFIX}${key}`, JSON.stringify(value), 'EX', ttlSeconds)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread apps/sim/lib/auth/auth.ts
* counts hold across replicas and deploys; otherwise `undefined`, leaving
* better-auth on its default in-memory store.
*/
const authRateLimitStorage = buildRedisRateLimitStorage()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Thread signupRateLimitWindowSeconds into buildRedisRateLimitStorage so the Redis TTL is always at least as long as the configured window, preventing mid-window key expiry when the window exceeds the current hardcoded 3600 s.

Suggested change
const authRateLimitStorage = buildRedisRateLimitStorage()
const authRateLimitStorage = buildRedisRateLimitStorage(signupRateLimitWindowSeconds)

Comment thread apps/sim/lib/auth/auth.ts
// onboarding hit different endpoints that this rule does not touch.
'/sign-up/email': { window: signupRateLimitWindowSeconds, max: signupRateLimitMax },
},
...(authRateLimitStorage ? { customStorage: authRateLimitStorage } : {}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 customStorage scopes wider than /sign-up/email

customStorage is a top-level rateLimit option in better-auth and applies to all rate-limit rules — including the built-in burst-protection rule (/sign-up* at 3 req / 10 s) and any other special rules the library applies globally. The PR description presents this as targeted signup storage, but in practice every auth-path rate-limit key hits Redis on every auth request. This is likely fine for the intended deployment, but worth being deliberate: if only the long-window signup counter should be Redis-backed, better-auth doesn't currently offer per-rule storage isolation (confirmed in issue #4497).

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread apps/sim/lib/auth/auth.ts
Comment on lines +175 to +177
const signupRateLimitWindowSeconds =
Number(env.SIGNUP_RATE_LIMIT_WINDOW_SECONDS) || DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS
const signupRateLimitMax = Number(env.SIGNUP_RATE_LIMIT_MAX) || DEFAULT_SIGNUP_RATE_LIMIT_MAX
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When SIGNUP_RATE_LIMIT_MAX or SIGNUP_RATE_LIMIT_WINDOW_SECONDS is set to "0", Number("0") evaluates to 0, which is falsy, so the || fallback silently restores the default instead of respecting the operator's explicit zero.

Suggested change
const signupRateLimitWindowSeconds =
Number(env.SIGNUP_RATE_LIMIT_WINDOW_SECONDS) || DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS
const signupRateLimitMax = Number(env.SIGNUP_RATE_LIMIT_MAX) || DEFAULT_SIGNUP_RATE_LIMIT_MAX
const signupRateLimitWindowSeconds =
(env.SIGNUP_RATE_LIMIT_WINDOW_SECONDS !== undefined
? Number(env.SIGNUP_RATE_LIMIT_WINDOW_SECONDS)
: NaN) || DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS
const signupRateLimitMax =
(env.SIGNUP_RATE_LIMIT_MAX !== undefined ? Number(env.SIGNUP_RATE_LIMIT_MAX) : NaN) ||
DEFAULT_SIGNUP_RATE_LIMIT_MAX

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant