Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e788636
Feature: Implement Sign-Up Rate Limiting
ben-fornefeld Sep 1, 2025
97d71d1
patch: Update Sign-Up Rate Limiting Flag Logic
ben-fornefeld Sep 1, 2025
2aeb968
remove: manual reset since ttl resets
ben-fornefeld Sep 1, 2025
a6f4fa9
feat: Add Upstash Rate Limiting for Sign-Up Process
ben-fornefeld Sep 9, 2025
1c8e1ed
refactor: Reorganize OTP verification logic in auth confirmation route
ben-fornefeld Sep 9, 2025
89caac5
refactor: Update rate limiting logic and environment schema for sign-…
ben-fornefeld Sep 9, 2025
424358e
refactor: Update environment variables and rate limiting configuratio…
ben-fornefeld Sep 9, 2025
228ae45
fix: Validate IP address in auth confirmation and sign-up actions
ben-fornefeld Sep 9, 2025
87fc2c3
refactor: Enhance rate limiting configuration with positive number va…
ben-fornefeld Sep 9, 2025
2a92b5a
refactor: Improve IP address handling and logging in rate limiting
ben-fornefeld Sep 9, 2025
b17f560
Merge branch 'main' into feat-sign-up-request-limiting-per-ip-address…
ben-fornefeld Sep 25, 2025
ada193f
refactor: Simplify rate limiting logic and remove deprecated configur…
ben-fornefeld Sep 25, 2025
a950aa5
refactor: Remove rate limiting logic from sign-up confirmation route
ben-fornefeld Sep 25, 2025
b9adb0f
refactor: rate limit handling to simplify
ben-fornefeld Sep 26, 2025
3f7d1d4
chore: clean up
ben-fornefeld Sep 26, 2025
9fbb68b
wip: fix race condition
ben-fornefeld Sep 26, 2025
2ac2a5c
remove: rate limit lib in favor of incr/decr
ben-fornefeld Oct 7, 2025
2010cc0
refactor: use custom lua script to ensure atomic mutations
ben-fornefeld Oct 7, 2025
b837c7c
fix: missing await
ben-fornefeld Oct 7, 2025
5b7ad4b
fix: ensure correct number passing + ensure ttl is always set
ben-fornefeld Oct 7, 2025
0f99957
refactor: streamline sign-up rate limiting by using default values an…
ben-fornefeld Oct 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/app/api/auth/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ENABLE_SIGN_UP_RATE_LIMITING } from '@/configs/flags'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { l } from '@/lib/clients/logger/logger'
import { createClient } from '@/lib/clients/supabase/server'
import { encodedRedirect } from '@/lib/utils/auth'
import { incrementSignUpAttempts } from '@/server/auth/rate-limiting'
import { redirect } from 'next/navigation'
import { NextRequest, NextResponse } from 'next/server'
import { serializeError } from 'serialize-error'
Expand Down Expand Up @@ -135,6 +137,17 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(redirectUrl.toString())
}

// increment counter for successful sign-up confirmations (rate limiting)
if (ENABLE_SIGN_UP_RATE_LIMITING && supabaseType === 'signup') {
const ip =
request.headers.get('x-forwarded-for') ||
request.headers.get('cf-connecting-ip') ||
request.headers.get('x-real-ip') ||
'unknown'

await incrementSignUpAttempts(ip)
}

l.info({
key: 'auth_confirm:success',
user_id: data?.user?.id,
Expand Down
4 changes: 4 additions & 0 deletions src/configs/flags.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export const ALLOW_SEO_INDEXING = process.env.ALLOW_SEO_INDEXING === '1'
export const VERBOSE = process.env.NEXT_PUBLIC_VERBOSE === '1'
export const INCLUDE_BILLING = process.env.NEXT_PUBLIC_INCLUDE_BILLING === '1'
export const ENABLE_SIGN_UP_RATE_LIMITING =
process.env.ENABLE_SIGN_UP_RATE_LIMITING === '1' &&
process.env.KV_REST_API_URL &&
process.env.KV_REST_API_TOKEN
Copy link

Choose a reason for hiding this comment

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

Bug: Rate Limiting Flag Evaluates Incorrectly

The ENABLE_SIGN_UP_RATE_LIMITING flag's logic is flawed. It evaluates to a non-boolean string (e.g., KV_REST_API_TOKEN's value or an empty string) when enabled, which may cause unexpected conditional behavior. It also treats empty strings for KV_REST_API_URL and KV_REST_API_TOKEN as valid, potentially leading to silent rate limiting failures.

Fix in Cursor Fix in Web

1 change: 1 addition & 0 deletions src/configs/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export const KV_KEYS = {
TEAM_SLUG_TO_ID: (slug: string) => `team-slug:${slug}:id`,
TEAM_ID_TO_SLUG: (teamId: string) => `team-id:${teamId}:slug`,
WARNED_ALTERNATE_EMAIL: (email: string) => `warned-alternate-email:${email}`,
SIGN_UP_RATE_LIMIT: (identifier: string) => `signup_rate_limit:${identifier}`,
}
13 changes: 9 additions & 4 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from 'zod'

const TruethyOrFalsy = z.enum(['1', '0'])

export const serverSchema = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
INFRA_API_URL: z.string().url(),
Expand All @@ -9,6 +11,9 @@ export const serverSchema = z.object({

BILLING_API_URL: z.string().url().optional(),
ZEROBOUNCE_API_KEY: z.string().optional(),
ENABLE_SIGN_UP_RATE_LIMITING: TruethyOrFalsy.optional(),
SIGN_UP_LIMIT_PER_WINDOW: z.coerce.number().optional(),
SIGN_UP_WINDOW_HOURS: z.coerce.number().optional(),

OTEL_SERVICE_NAME: z.string().optional(),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
Expand Down Expand Up @@ -41,10 +46,10 @@ export const clientSchema = z.object({
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),

NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1).optional(),
NEXT_PUBLIC_INCLUDE_BILLING: z.string().optional(),
NEXT_PUBLIC_SCAN: z.string().optional(),
NEXT_PUBLIC_MOCK_DATA: z.string().optional(),
NEXT_PUBLIC_VERBOSE: z.string().optional(),
NEXT_PUBLIC_INCLUDE_BILLING: TruethyOrFalsy.optional(),
NEXT_PUBLIC_SCAN: TruethyOrFalsy.optional(),
NEXT_PUBLIC_MOCK_DATA: TruethyOrFalsy.optional(),
NEXT_PUBLIC_VERBOSE: TruethyOrFalsy.optional(),
})

export const testEnvSchema = z.object({
Expand Down
18 changes: 18 additions & 0 deletions src/server/auth/auth-actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use server'

import { ENABLE_SIGN_UP_RATE_LIMITING } from '@/configs/flags'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { USER_MESSAGES } from '@/configs/user-messages'
import { actionClient } from '@/lib/clients/action'
Expand All @@ -17,6 +18,7 @@ import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types'
import { isSignUpRateLimited } from './rate-limiting'

export const signInWithOAuthAction = actionClient
.schema(
Expand Down Expand Up @@ -93,6 +95,18 @@ export const signUpAction = actionClient
}
}

const ip =
(await headers()).get('x-forwarded-for') ||
(await headers()).get('cf-connecting-ip') ||
(await headers()).get('x-real-ip') ||
'unknown'

if (ENABLE_SIGN_UP_RATE_LIMITING && (await isSignUpRateLimited(ip))) {
return returnServerError(
'Too many sign-ups for now. Please try again later.'
)
}

const { error } = await supabase.auth.signUp({
email,
password,
Expand All @@ -112,6 +126,10 @@ export const signUpAction = actionClient
return returnServerError(USER_MESSAGES.emailInUse.message)
case 'weak_password':
return returnServerError(USER_MESSAGES.passwordWeak.message)
case 'email_address_invalid':
return returnServerError(
USER_MESSAGES.signUpEmailValidationInvalid.message
)
default:
throw error
}
Expand Down
74 changes: 74 additions & 0 deletions src/server/auth/rate-limiting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { KV_KEYS } from '@/configs/keys'
import { kv } from '@/lib/clients/kv'
import { l } from '@/lib/clients/logger/logger'
import { serializeError } from 'serialize-error'

const SIGN_UP_LIMIT_PER_WINDOW =
Number(process.env.SIGN_UP_LIMIT_PER_WINDOW) || 1
const SIGN_UP_WINDOW_HOURS = Number(process.env.SIGN_UP_WINDOW_HOURS) || 24

/**
* Check if an identifier (IP address) has exceeded the successful sign-up limit
* @param identifier - IP address to check rate limiting for
* @returns Promise<boolean> - true if rate limited, false if allowed
*/
export async function isSignUpRateLimited(
identifier: string
): Promise<boolean> {
try {
const key = KV_KEYS.SIGN_UP_RATE_LIMIT(identifier)
const attempts = await kv.get(key)

if (!attempts) {
return false
}

const attemptCount = parseInt(attempts as string, 10)
return attemptCount >= SIGN_UP_LIMIT_PER_WINDOW
} catch (error) {
l.error({
key: 'sign_up_rate_limit:check_error',
error: serializeError(error),
context: {
identifier,
},
})
// on error, allow the request to proceed
return false
}
}

/**
* Increment the successful sign-up counter for an identifier (IP)
* @param identifier - IP address to increment counter for
*/
export async function incrementSignUpAttempts(
identifier: string
): Promise<void> {
try {
const key = KV_KEYS.SIGN_UP_RATE_LIMIT(identifier)
const current = await kv.get(key)
const currentCount = current ? parseInt(current as string, 10) : 0
const newCount = currentCount + 1

await kv.set(key, newCount.toString(), {
ex: SIGN_UP_WINDOW_HOURS * 3600,
})

l.debug({
key: 'sign_up_rate_limit:increment',
context: {
identifier,
attempts: newCount,
},
})
} catch (error) {
l.error({
key: 'sign_up_rate_limit:increment_error',
error: serializeError(error),
context: {
identifier,
},
})
}
}