Skip to content

Commit 75c2c6d

Browse files
waleedlatif1claude
andcommitted
feat(auth): add per-IP signup rate limit with Redis-backed storage
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>
1 parent 919fa52 commit 75c2c6d

4 files changed

Lines changed: 178 additions & 0 deletions

File tree

apps/sim/lib/auth/auth.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
resolveClientMetadata,
3939
upsertCimdClient,
4040
} from '@/lib/auth/cimd'
41+
import { buildRedisRateLimitStorage } from '@/lib/auth/rate-limit-storage'
4142
import { sendPlanWelcomeEmail } from '@/lib/billing'
4243
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
4344
import {
@@ -168,6 +169,20 @@ function isSignupEmailBlocked(email: string | undefined | null): boolean {
168169
return isEmailInDenylist(email, blockedSignupDomains)
169170
}
170171

172+
const DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS = 3600
173+
const DEFAULT_SIGNUP_RATE_LIMIT_MAX = 20
174+
175+
const signupRateLimitWindowSeconds =
176+
Number(env.SIGNUP_RATE_LIMIT_WINDOW_SECONDS) || DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS
177+
const signupRateLimitMax = Number(env.SIGNUP_RATE_LIMIT_MAX) || DEFAULT_SIGNUP_RATE_LIMIT_MAX
178+
179+
/**
180+
* Shared store for the auth rate limiter. Redis-backed when configured so per-IP
181+
* counts hold across replicas and deploys; otherwise `undefined`, leaving
182+
* better-auth on its default in-memory store.
183+
*/
184+
const authRateLimitStorage = buildRedisRateLimitStorage()
185+
171186
const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
172187
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
173188
)
@@ -204,6 +219,28 @@ export const auth = betterAuth({
204219
provider: 'pg',
205220
schema,
206221
}),
222+
rateLimit: {
223+
// Mirror better-auth's default (on in production, off in dev/test) so local
224+
// signup flows stay unthrottled.
225+
enabled: env.NODE_ENV === 'production',
226+
customRules: {
227+
// Long-window per-IP cap on email signup. better-auth's built-in rule only
228+
// guards bursts (3 / 10s), which a slow drip from one IP sails past; this
229+
// bounds sustained abuse. Deliberately generous — an enterprise office
230+
// behind a single NAT signs up well under this, and SSO / org-invite
231+
// onboarding hit different endpoints that this rule does not touch.
232+
'/sign-up/email': { window: signupRateLimitWindowSeconds, max: signupRateLimitMax },
233+
},
234+
...(authRateLimitStorage ? { customStorage: authRateLimitStorage } : {}),
235+
},
236+
advanced: {
237+
ipAddress: {
238+
// Prefer Cloudflare's client IP (set by the edge, not forgeable by the
239+
// caller) so the rate-limit key is the real client. Fall back to
240+
// X-Forwarded-For for deployments without Cloudflare in front.
241+
ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'],
242+
},
243+
},
207244
session: {
208245
cookieCache: {
209246
enabled: true,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetRedisClient, redis } = vi.hoisted(() => {
7+
const redis = {
8+
get: vi.fn(),
9+
set: vi.fn(),
10+
}
11+
return { mockGetRedisClient: vi.fn(), redis }
12+
})
13+
14+
vi.mock('@/lib/core/config/redis', () => ({
15+
getRedisClient: mockGetRedisClient,
16+
}))
17+
18+
import { buildRedisRateLimitStorage } from '@/lib/auth/rate-limit-storage'
19+
20+
describe('buildRedisRateLimitStorage', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
mockGetRedisClient.mockReturnValue(redis)
24+
})
25+
26+
it('returns undefined when Redis is not configured (falls back to in-memory)', () => {
27+
mockGetRedisClient.mockReturnValue(null)
28+
expect(buildRedisRateLimitStorage()).toBeUndefined()
29+
})
30+
31+
it('reads and parses a stored counter', async () => {
32+
const storage = buildRedisRateLimitStorage()
33+
redis.get.mockResolvedValue(JSON.stringify({ key: 'k', count: 4, lastRequest: 123 }))
34+
const value = await storage?.get('k')
35+
expect(redis.get).toHaveBeenCalledWith('auth-rl:k')
36+
expect(value).toEqual({ key: 'k', count: 4, lastRequest: 123 })
37+
})
38+
39+
it('returns undefined when no counter is stored', async () => {
40+
const storage = buildRedisRateLimitStorage()
41+
redis.get.mockResolvedValue(null)
42+
expect(await storage?.get('missing')).toBeUndefined()
43+
})
44+
45+
it('writes the counter with a bounding TTL', async () => {
46+
const storage = buildRedisRateLimitStorage()
47+
await storage?.set('k', { key: 'k', count: 1, lastRequest: 999 })
48+
expect(redis.set).toHaveBeenCalledWith(
49+
'auth-rl:k',
50+
JSON.stringify({ key: 'k', count: 1, lastRequest: 999 }),
51+
'EX',
52+
3600
53+
)
54+
})
55+
56+
it('fails open on a read error (allows the request)', async () => {
57+
const storage = buildRedisRateLimitStorage()
58+
redis.get.mockRejectedValue(new Error('redis down'))
59+
expect(await storage?.get('k')).toBeUndefined()
60+
})
61+
62+
it('swallows write errors so a Redis outage never blocks auth', async () => {
63+
const storage = buildRedisRateLimitStorage()
64+
redis.set.mockRejectedValue(new Error('redis down'))
65+
await expect(storage?.set('k', { key: 'k', count: 1, lastRequest: 1 })).resolves.toBeUndefined()
66+
})
67+
})
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { getRedisClient } from '@/lib/core/config/redis'
4+
5+
const logger = createLogger('AuthRateLimitStorage')
6+
7+
/** Counter shape better-auth persists per rate-limit key. */
8+
interface RateLimitRecord {
9+
key: string
10+
count: number
11+
lastRequest: number
12+
}
13+
14+
/** Structural match for better-auth's `rateLimit.customStorage` option. */
15+
interface RateLimitStorage {
16+
get: (key: string) => Promise<RateLimitRecord | undefined>
17+
set: (key: string, value: RateLimitRecord, update?: boolean) => Promise<void>
18+
}
19+
20+
const REDIS_KEY_PREFIX = 'auth-rl:'
21+
22+
/**
23+
* TTL for stored counters. Correctness comes from the `lastRequest` timestamp
24+
* comparison in better-auth's limiter, not from expiry — the TTL only bounds
25+
* key growth. It must be at least as long as the largest configured window so a
26+
* key never expires mid-window and under-counts.
27+
*/
28+
const REDIS_TTL_SECONDS = 3600
29+
30+
/**
31+
* Redis-backed storage for better-auth's rate limiter.
32+
*
33+
* Returns `undefined` when no Redis is configured, in which case better-auth
34+
* falls back to its default in-memory store. That store is per-process and
35+
* resets on deploy, so a long-window per-IP cap only holds across a multi-replica
36+
* deployment when counts live in shared storage. Backing the limiter with Redis
37+
* (rather than the primary database) keeps the per-request counter I/O off the
38+
* Postgres primary.
39+
*
40+
* Fail-open: any Redis error resolves `get` to `undefined` (treated as no prior
41+
* requests) and makes `set` a no-op, so a Redis outage degrades to "allow"
42+
* rather than locking users out of authentication.
43+
*/
44+
export function buildRedisRateLimitStorage(): RateLimitStorage | undefined {
45+
if (!getRedisClient()) return undefined
46+
47+
return {
48+
get: async (key) => {
49+
try {
50+
const redis = getRedisClient()
51+
if (!redis) return undefined
52+
const raw = await redis.get(`${REDIS_KEY_PREFIX}${key}`)
53+
if (!raw) return undefined
54+
return JSON.parse(raw) as RateLimitRecord
55+
} catch (error) {
56+
logger.warn('Rate-limit storage read failed; allowing request', {
57+
error: getErrorMessage(error),
58+
})
59+
return undefined
60+
}
61+
},
62+
set: async (key, value) => {
63+
try {
64+
const redis = getRedisClient()
65+
if (!redis) return
66+
await redis.set(`${REDIS_KEY_PREFIX}${key}`, JSON.stringify(value), 'EX', REDIS_TTL_SECONDS)
67+
} catch (error) {
68+
logger.warn('Rate-limit storage write failed', { error: getErrorMessage(error) })
69+
}
70+
},
71+
}
72+
}

apps/sim/lib/core/config/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const env = createEnv({
2929
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
3030
SIGNUP_MX_VALIDATION_ENABLED: z.boolean().optional(), // Opt-in: validate the email's MX backend at signup (blocks no-MX domains and denylisted shared spam backends). Off by default; enable on hosted/abuse-targeted deployments.
3131
BLOCKED_EMAIL_MX_HOSTS: z.string().optional(), // Comma-separated MX-host substrings blocked from signing up; matched against the domain's resolved MX backend to catch throwaway domains that share a mail backend. No defaults — operators supply their own list. Only used when SIGNUP_MX_VALIDATION_ENABLED is set.
32+
SIGNUP_RATE_LIMIT_WINDOW_SECONDS: z.string().optional(), // Per-IP rate-limit window (seconds) for /sign-up/email. Default 3600 (1h). Backed by Redis when REDIS_URL is set (consistent across replicas); falls back to better-auth's in-memory store otherwise.
33+
SIGNUP_RATE_LIMIT_MAX: z.string().optional(), // Max email signups per IP per window before HTTP 429. Default 20 — generous for enterprise office NATs; SSO and org-invite onboarding use different endpoints and are unaffected.
3234
TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins.
3335
TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification
3436
SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains)

0 commit comments

Comments
 (0)