Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 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
10 changes: 10 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
"@theguild/remark-mermaid": "^0.2.0",
"@types/mdx": "^2.0.13",
"@types/micromatch": "^4.0.9",
"@upstash/ratelimit": "^2.0.6",
"@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 @@ -1162,12 +1164,20 @@

"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],

"@upstash/core-analytics": ["@upstash/[email protected]", "", { "dependencies": { "@upstash/redis": "^1.28.3" } }, "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ=="],

"@upstash/ratelimit": ["@upstash/[email protected]", "", { "dependencies": { "@upstash/core-analytics": "^0.0.10" }, "peerDependencies": { "@upstash/redis": "^1.34.3" } }, "sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg=="],

"@upstash/redis": ["@upstash/[email protected]", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w=="],

"@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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@
"@theguild/remark-mermaid": "^0.2.0",
"@types/mdx": "^2.0.13",
"@types/micromatch": "^4.0.9",
"@upstash/ratelimit": "^2.0.6",
"@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'
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
47 changes: 46 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,13 @@ 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 { incrementSignUpRateLimit, isSignUpRateLimited } from './ratelimit'

export const signInWithOAuthAction = actionClient
.schema(
Expand Down Expand Up @@ -78,7 +81,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 +110,32 @@ export const signUpAction = actionClient
}
}

// RATE LIMITING

const ip = ipAddress(headersStore)

if (ENABLE_SIGN_UP_RATE_LIMITING && process.env.NODE_ENV === 'production') {
if (ip && (await isSignUpRateLimited(ip))) {
return returnServerError(
'Too many sign-up attempts. Please try again later.'
)
}

if (!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 ip headers were found in production.'
)
}
}

// SIGN UP

const { error } = await supabase.auth.signUp({
email,
password,
Expand All @@ -122,10 +155,22 @@ 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
}
}

if (
ENABLE_SIGN_UP_RATE_LIMITING &&
process.env.NODE_ENV === 'production' &&
ip
) {
await incrementSignUpRateLimit(ip)
}
})

export const signInAction = actionClient
Expand Down
45 changes: 45 additions & 0 deletions src/server/auth/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'server-cli-only'

import { l } from '@/lib/clients/logger/logger'
import { Duration, Ratelimit } from '@upstash/ratelimit'
import { kv } from '@vercel/kv'

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

const SIGN_UP_WINDOW: Duration = `${SIGN_UP_WINDOW_HOURS}h`

const _ratelimit = new Ratelimit({
redis: kv,
limiter: Ratelimit.slidingWindow(SIGN_UP_LIMIT_PER_WINDOW, SIGN_UP_WINDOW),
})

export async function incrementSignUpRateLimit(
identifier: string
): Promise<boolean> {
const result = await _ratelimit.limit(identifier)

if (!result.success) {
l.error({
key: 'sign_up_rate_limit_increment:limit_error',
context: {
identifier,
result,
},
})

return false
}

return result.remaining === 0
}

export async function isSignUpRateLimited(
identifier: string
): Promise<boolean> {
const result = await _ratelimit.getRemaining(identifier)

return result.remaining === 0
}
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