Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
### OPTIONAL SERVER ENVIRONMENT VARIABLES
### =================================

### Auth Rate Limiting
# ENABLE_SIGN_UP_RATE_LIMITING=1
# SIGN_UP_LIMIT_PER_WINDOW=1
# SIGN_UP_WINDOW_HOURS=24

### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1)
# BILLING_API_URL=https://billing.e2b.dev

Expand Down
5 changes: 5 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@types/mdx": "^2.0.13",
"@types/micromatch": "^4.0.9",
"@vercel/analytics": "^1.5.0",
"@vercel/functions": "^3.1.0",
"@vercel/kv": "^3.0.0",
"@vercel/otel": "^1.13.0",
"@vercel/speed-insights": "^1.2.0",
Expand Down Expand Up @@ -1166,8 +1167,12 @@

"@vercel/analytics": ["@vercel/[email protected]", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g=="],

"@vercel/functions": ["@vercel/[email protected]", "", { "dependencies": { "@vercel/oidc": "3.0.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-V+p8dO+sg1VjiJJUO5rYPp1KG17SzDcR74OWwW7Euyde6L8U5wuTMe9QfEOfLTiWPUPzN1MXZvLcYxqSYhKc4Q=="],

"@vercel/kv": ["@vercel/[email protected]", "", { "dependencies": { "@upstash/redis": "^1.34.0" } }, "sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg=="],

"@vercel/oidc": ["@vercel/[email protected]", "", { "dependencies": { "@types/ms": "2.1.0", "ms": "2.1.3" } }, "sha512-XOoUcf/1VfGArUAfq0ELxk6TD7l4jGcrOsWjQibj4wYM74uNihzZ9gA46ywWegoqKWWdph4y5CKxGI9823deoA=="],

"@vercel/otel": ["@vercel/[email protected]", "", { "peerDependencies": { "@opentelemetry/api": ">=1.7.0 <2.0.0", "@opentelemetry/api-logs": ">=0.46.0 <0.200.0", "@opentelemetry/instrumentation": ">=0.46.0 <0.200.0", "@opentelemetry/resources": ">=1.19.0 <2.0.0", "@opentelemetry/sdk-logs": ">=0.46.0 <0.200.0", "@opentelemetry/sdk-metrics": ">=1.19.0 <2.0.0", "@opentelemetry/sdk-trace-base": ">=1.19.0 <2.0.0" } }, "sha512-esRkt470Y2jRK1B1g7S1vkt4Csu44gp83Zpu8rIyPoqy2BKgk4z7ik1uSMswzi45UogLHFl6yR5TauDurBQi4Q=="],

"@vercel/speed-insights": ["@vercel/[email protected]", "", { "peerDependencies": { "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw=="],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"@types/mdx": "^2.0.13",
"@types/micromatch": "^4.0.9",
"@vercel/analytics": "^1.5.0",
"@vercel/functions": "^3.1.0",
"@vercel/kv": "^3.0.0",
"@vercel/otel": "^1.13.0",
"@vercel/speed-insights": "^1.2.0",
Expand Down
4 changes: 4 additions & 0 deletions src/configs/flags.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
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

export const USE_MOCK_DATA =
process.env.VERCEL_ENV !== 'production' &&
process.env.NEXT_PUBLIC_MOCK_DATA === '1'
1 change: 1 addition & 0 deletions src/configs/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ 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}`,
RATE_LIMIT_SIGN_UP: (identifier: string) => `ratelimit:sign-up:${identifier}`,
}

/*
Expand Down
4 changes: 4 additions & 0 deletions src/configs/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const DEFAULT_SIGN_UP_LIMIT_PER_WINDOW = 3
const DEFAULT_SIGN_UP_WINDOW_HOURS = 24

export { DEFAULT_SIGN_UP_LIMIT_PER_WINDOW, DEFAULT_SIGN_UP_WINDOW_HOURS }
25 changes: 21 additions & 4 deletions src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { z } from 'zod'

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

// string that must parse as a positive number
const StringPositiveNumber = z.string().refine(
(val) => {
const num = Number(val)
return !isNaN(num) && num > 0
},
{
message: 'Must be a string that can be parsed as a positive number',
}
)

export const serverSchema = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
INFRA_API_URL: z.string().url(),
Expand All @@ -10,6 +23,10 @@ export const serverSchema = z.object({
BILLING_API_URL: z.string().url().optional(),
ZEROBOUNCE_API_KEY: z.string().optional(),

ENABLE_SIGN_UP_RATE_LIMITING: NumericBoolean.optional(),
SIGN_UP_LIMIT_PER_WINDOW: StringPositiveNumber.optional(),
SIGN_UP_WINDOW_HOURS: StringPositiveNumber.optional(),

OTEL_SERVICE_NAME: z.string().optional(),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
OTEL_EXPORTER_OTLP_PROTOCOL: z
Expand Down Expand Up @@ -41,10 +58,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: NumericBoolean.optional(),
NEXT_PUBLIC_SCAN: NumericBoolean.optional(),
NEXT_PUBLIC_MOCK_DATA: NumericBoolean.optional(),
NEXT_PUBLIC_VERBOSE: NumericBoolean.optional(),
})

export const testEnvSchema = z.object({
Expand Down
56 changes: 55 additions & 1 deletion 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 @@ -13,11 +14,16 @@ import {
validateEmail,
} from '@/server/auth/validate-email'
import { Provider } from '@supabase/supabase-js'
import { ipAddress } from '@vercel/functions'
import { returnValidationErrors } from 'next-safe-action'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { forgotPasswordSchema, signInSchema, signUpSchema } from './auth.types'
import {
decrementSignUpRateLimit,
incrementAndCheckSignUpRateLimit,
} from './ratelimit'

export const signInWithOAuthAction = actionClient
.schema(
Expand Down Expand Up @@ -78,7 +84,11 @@ export const signUpAction = actionClient
.metadata({ actionName: 'signUp' })
.action(async ({ parsedInput: { email, password, returnTo = '' } }) => {
const supabase = await createClient()
const origin = (await headers()).get('origin') || ''
const headersStore = await headers()

const origin = headersStore.get('origin') || ''

// EMAIL VALIDATION

// basic security check, that password does not equal e-mail
if (password && email && password.toLowerCase() === email.toLowerCase()) {
Expand All @@ -103,6 +113,40 @@ export const signUpAction = actionClient
}
}

// RATE LIMITING

const ip = ipAddress(headersStore)

const shouldRateLimit =
ENABLE_SIGN_UP_RATE_LIMITING &&
process.env.NODE_ENV === 'production' &&
ip

if (
ENABLE_SIGN_UP_RATE_LIMITING &&
process.env.NODE_ENV === 'production' &&
!ip
) {
l.warn(
{
key: 'sign_up_rate_limit:no_ip_headers',
context: {
message: 'no ip headers found in production',
},
},
'Tried to rate limit, but no client ip headers were found in production.'
)
}

// increment rate limit counter before attempting signup
if (shouldRateLimit && (await incrementAndCheckSignUpRateLimit(ip))) {
return returnServerError(
'Too many sign-up attempts. Please try again later.'
)
}

// SIGN UP

const { error } = await supabase.auth.signUp({
email,
password,
Expand All @@ -117,11 +161,21 @@ export const signUpAction = actionClient
})

if (error) {
// decrement the sign up rate limit on failure,
// since no account was registered in the end.
if (shouldRateLimit) {
await decrementSignUpRateLimit(ip)
}

switch (error.code) {
case 'email_exists':
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
91 changes: 91 additions & 0 deletions src/server/auth/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'server-cli-only'

import { KV_KEYS } from '@/configs/keys'
import {
DEFAULT_SIGN_UP_LIMIT_PER_WINDOW,
DEFAULT_SIGN_UP_WINDOW_HOURS,
} from '@/configs/limits'
import { kv } from '@vercel/kv'

// we need to ensure the limits are valid numbers
// if parseInt returns NaN, we fallback to the default
const SIGN_UP_LIMIT_PER_WINDOW =
parseInt(
process.env.SIGN_UP_LIMIT_PER_WINDOW ||
DEFAULT_SIGN_UP_LIMIT_PER_WINDOW.toString()
) || DEFAULT_SIGN_UP_LIMIT_PER_WINDOW

const SIGN_UP_WINDOW_HOURS =
parseInt(
process.env.SIGN_UP_WINDOW_HOURS || DEFAULT_SIGN_UP_WINDOW_HOURS.toString()
) || DEFAULT_SIGN_UP_WINDOW_HOURS

/**
* Increments the sign-up attempt counter and checks if the rate limit has been reached.
* Uses a Lua script for atomic execution to avoid race conditions.
*
* The script:
* 1. Increments the counter
* 2. Sets TTL on first increment
* 3. Returns the new count
*
* @param identifier - The unique identifier (e.g., IP address) to track rate limit for
* @returns Promise<boolean> - Returns true if the rate limit has been exceeded (no more attempts allowed),
* false if more attempts are available
*/
export async function incrementAndCheckSignUpRateLimit(
identifier: string
): Promise<boolean> {
const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier)
const windowSeconds = SIGN_UP_WINDOW_HOURS * 60 * 60

// executes atomically on redis server
// we ensure TTL exists once per window, even if expire would fail on first increment for some reason
const luaScript = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
else
if redis.call('TTL', KEYS[1]) == -1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
end
return count
`

const count = await kv.eval<string[], number>(
luaScript,
[key],
[windowSeconds.toString()]
)
Copy link

Choose a reason for hiding this comment

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

Bug: Incorrect Type Parameters in Rate Limit Functions

The kv.eval calls in both rate limit functions use incorrect type parameters. The first type parameter, which should specify the Lua script's return type, is mistakenly set to string[]. The actual return types are number for incrementAndCheckSignUpRateLimit and number | string | null for decrementSignUpRateLimit.

Additional Locations (1)

Fix in Cursor Fix in Web


// return true if limit exceeded (rate limited)
return count > SIGN_UP_LIMIT_PER_WINDOW
}

/**
* Decrements the sign-up attempt counter when a sign-up fails.
* Uses a Lua script for atomic execution to avoid race conditions.
*
* The script only decrements if:
* 1. Key exists
* 2. Current count > 0
*
* This allows the user to retry since no account was actually created.
*
* @param identifier - The unique identifier whose rate limit should be decremented
*/
export async function decrementSignUpRateLimit(identifier: string) {
const key = KV_KEYS.RATE_LIMIT_SIGN_UP(identifier)

// executes atomically on redis server
const luaScript = `
local current = redis.call('GET', KEYS[1])
if current and tonumber(current) > 0 then
return redis.call('DECR', KEYS[1])
end
return current
`

await kv.eval<string[], number>(luaScript, [key], [])
}
22 changes: 17 additions & 5 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
Expand All @@ -12,18 +16,26 @@
"moduleResolution": "bundler",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
},
"isolatedModules": true
},
"include": ["next-env.d.ts", "src", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"src",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
Loading