[GH-ISSUE #1891] Rate Limit doesn't work for Email OTP #8964

Closed
opened 2026-04-13 04:12:36 -05:00 by GiteaMirror · 12 comments
Owner

Originally created by @dinogomez on GitHub (Mar 20, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/1891

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Use the Email OTP
  2. Add rate limiting with secondary storage (I'm using upstash redis)
  3. Spam the verification endpoint when submitting a wrong otp token.

Current vs. Expected behavior

                        "/sign-in/email-otp": {
				window: 300,
				max: 5,
			},
			"/sign-in/email": {
				window: 300,
				max: 3,
			},
			"/email-otp": {
				window: 300,
				max: 3,
			},
			"/send-verification-email": {
				window: 300,
				max: 3,
			},

I tried several paths, including the /api/auth/sign-in/email-otp but still doesn't work.

But im still able to spam the api.

POST /api/auth/sign-in/email-otp 400 in 450ms
 POST /api/auth/sign-in/email-otp 400 in 274ms
 POST /api/auth/sign-in/email-otp 400 in 276ms
 POST /api/auth/sign-in/email-otp 400 in 264ms
 POST /api/auth/sign-in/email-otp 400 in 265ms
 POST /api/auth/sign-in/email-otp 400 in 267ms
 POST /api/auth/sign-in/email-otp 400 in 274ms
 POST /api/auth/sign-in/email-otp 400 in 276ms
 POST /api/auth/sign-in/email-otp 400 in 267ms
 POST /api/auth/sign-in/email-otp 400 in 277ms
 POST /api/auth/sign-in/email-otp 400 in 271ms
 POST /api/auth/sign-in/email-otp 400 in 279ms
 POST /api/auth/sign-in/email-otp 400 in 734ms

What version of Better Auth are you using?

1.2.4

Provide environment information

- NextJS 15.2.2

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

export const auth = betterAuth({
	database: drizzleAdapter(db, {
		provider: "pg",
		schema: {
			user: schema.user,
			account: schema.account,
			session: schema.session,
			verification: schema.verification,
		},
	}),
	trustedOrigins: [
		"http://localhost:3000",
		"REDACTED",
		"REDACTED",
		"REDACTED",
	],
	rateLimit: {
		storage: "secondary-storage",
		window: 60, 
		max: 5, 
		customRules: {
			"/sign-in/email-otp": {
				window: 300,
				max: 5,
			},
			"/sign-in/email": {
				window: 300,
				max: 3,
			},
			"/email-otp": {
				window: 300,
				max: 3,
			},
			"/send-verification-email": {
				window: 300,
				max: 3,
			},
		},
	},
	secondaryStorage: {
		get: async (key) => {
			const value = await redis.get(key);
			return value ? String(value) : null;
		},
		set: async (key, value, ttl) => {
			if (ttl) {
				await redis.set(key, value, { ex: ttl });
			} else {
				await redis.set(key, value);
			}
		},
		delete: async (key) => {
			await redis.del(key);
		},
	},
	plugins: [
		emailOTP({
			REDACTED
		}),
	],
});

Additional context

No response

Originally created by @dinogomez on GitHub (Mar 20, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/1891 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Use the Email OTP 2. Add rate limiting with secondary storage (I'm using upstash redis) 3. Spam the verification endpoint when submitting a wrong otp token. ### Current vs. Expected behavior ```ts "/sign-in/email-otp": { window: 300, max: 5, }, "/sign-in/email": { window: 300, max: 3, }, "/email-otp": { window: 300, max: 3, }, "/send-verification-email": { window: 300, max: 3, }, ``` I tried several paths, including the `/api/auth/sign-in/email-otp` but still doesn't work. But im still able to spam the api. ``` POST /api/auth/sign-in/email-otp 400 in 450ms POST /api/auth/sign-in/email-otp 400 in 274ms POST /api/auth/sign-in/email-otp 400 in 276ms POST /api/auth/sign-in/email-otp 400 in 264ms POST /api/auth/sign-in/email-otp 400 in 265ms POST /api/auth/sign-in/email-otp 400 in 267ms POST /api/auth/sign-in/email-otp 400 in 274ms POST /api/auth/sign-in/email-otp 400 in 276ms POST /api/auth/sign-in/email-otp 400 in 267ms POST /api/auth/sign-in/email-otp 400 in 277ms POST /api/auth/sign-in/email-otp 400 in 271ms POST /api/auth/sign-in/email-otp 400 in 279ms POST /api/auth/sign-in/email-otp 400 in 734ms ``` ### What version of Better Auth are you using? 1.2.4 ### Provide environment information ```bash - NextJS 15.2.2 ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema: { user: schema.user, account: schema.account, session: schema.session, verification: schema.verification, }, }), trustedOrigins: [ "http://localhost:3000", "REDACTED", "REDACTED", "REDACTED", ], rateLimit: { storage: "secondary-storage", window: 60, max: 5, customRules: { "/sign-in/email-otp": { window: 300, max: 5, }, "/sign-in/email": { window: 300, max: 3, }, "/email-otp": { window: 300, max: 3, }, "/send-verification-email": { window: 300, max: 3, }, }, }, secondaryStorage: { get: async (key) => { const value = await redis.get(key); return value ? String(value) : null; }, set: async (key, value, ttl) => { if (ttl) { await redis.set(key, value, { ex: ttl }); } else { await redis.set(key, value); } }, delete: async (key) => { await redis.del(key); }, }, plugins: [ emailOTP({ REDACTED }), ], }); ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-13 04:12:36 -05:00
Author
Owner

@btnalexandre commented on GitHub (Mar 20, 2025):

Hello @dinogomez

Same problem for me. It's seems like a mistake on the documentation.

You need to pass this option enabled: true to rateLimitobject, like this :

 rateLimit: {
    enabled: true, // <--- here is NEEDED
    window: 60, // time window in seconds
    max: 50, // max requests in the window
    customRules: {
      "/sign-in/email-otp": {
        window: 30,
        max: 5,
      },
      "/email-otp/send-verification-otp": {
        window: 60,
        max: 5,
      },
    },
    storage: "database",
  },
<!-- gh-comment-id:2741017145 --> @btnalexandre commented on GitHub (Mar 20, 2025): Hello @dinogomez Same problem for me. It's seems like a mistake on the documentation. You need to pass this option `enabled: true` to `rateLimit`object, like this : ```javascript rateLimit: { enabled: true, // <--- here is NEEDED window: 60, // time window in seconds max: 50, // max requests in the window customRules: { "/sign-in/email-otp": { window: 30, max: 5, }, "/email-otp/send-verification-otp": { window: 60, max: 5, }, }, storage: "database", }, ```
Author
Owner

@dinogomez commented on GitHub (Mar 20, 2025):

Hi @btnalexandre ,

Didn't notice the enabled: true property thanks! But after that I'm starting to get status 500 errors on the api routes after setting enabled to true. I'm using upstash for the redis secondary-storage

 ⨯ SyntaxError: "[object Object]" is not valid JSON
    at JSON.parse (<anonymous>)
 ⨯ SyntaxError: "[object Object]" is not valid JSON
    at JSON.parse (<anonymous>)
 GET /api/auth/get-session 500 in 1003ms
Auth client using base URL: http://localhost:3000
 GET / 200 in 286ms
 ⨯ SyntaxError: "[object Object]" is not valid JSON
    at JSON.parse (<anonymous>)
 ⨯ SyntaxError: "[object Object]" is not valid JSON
    at JSON.parse (<anonymous>)
 POST /api/auth/email-otp/send-verification-otp 500 in 59

This is my current auth config

import VerificationEmail from "@/components/email/VerificationEmail";
import { db } from "@/db/drizzle";
import * as schema from "@/db/schema";
import { render } from "@react-email/render";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { emailOTP } from "better-auth/plugins";
import * as React from "react";
import { Resend } from "resend";
import { redis } from "./upstash";

const resend = new Resend(process.env.RESEND_API_KEY);

export const auth = betterAuth({
	database: drizzleAdapter(db, {
		provider: "pg",
		schema: {
			user: schema.user,
			account: schema.account,
			session: schema.session,
			verification: schema.verification,
		},
	}),
	trustedOrigins: [
		"http://localhost:3000",
		"https://dev.redacted.app",
		"https://stage.redacted.app",
		"https://redacted.app",
	],
	cookies: {
		session: {
			name: "better-auth.session_token",
			options: {
				httpOnly: true,
				secure: process.env.NODE_ENV === "production",
				sameSite: "lax",
				path: "/",
				domain:
					process.env.NODE_ENV === "production" ? ".redacted.app" : undefined,
			},
		},
	},
	rateLimit: {
		enabled: true,
		window: 60, // time window in seconds
		max: 50, // max requests in the window
		customRules: {
			"/sign-in/email-otp": {
				window: 30,
				max: 5,
			},
			"/email-otp/send-verification-otp": {
				window: 60,
				max: 5,
			},
		},
		storage: "secondary-storage",
	},
	secondaryStorage: {
		get: async (key) => {
			const value = await redis.get(key);
			return value ? String(value) : null;
		},
		set: async (key, value, ttl) => {
			if (ttl) {
				await redis.set(key, value, { ex: ttl });
			} else {
				await redis.set(key, value);
			}
		},
		delete: async (key) => {
			await redis.del(key);
		},
	},
	plugins: [
		emailOTP({
                      //Sends an email through resend
		}),
	],
});
<!-- gh-comment-id:2741186709 --> @dinogomez commented on GitHub (Mar 20, 2025): Hi @btnalexandre , Didn't notice the `enabled: true` property thanks! But after that I'm starting to get status `500` errors on the api routes after setting `enabled` to `true`. I'm using upstash for the redis secondary-storage ```bash ⨯ SyntaxError: "[object Object]" is not valid JSON at JSON.parse (<anonymous>) ⨯ SyntaxError: "[object Object]" is not valid JSON at JSON.parse (<anonymous>) GET /api/auth/get-session 500 in 1003ms Auth client using base URL: http://localhost:3000 GET / 200 in 286ms ⨯ SyntaxError: "[object Object]" is not valid JSON at JSON.parse (<anonymous>) ⨯ SyntaxError: "[object Object]" is not valid JSON at JSON.parse (<anonymous>) POST /api/auth/email-otp/send-verification-otp 500 in 59 ``` This is my current auth config ```ts import VerificationEmail from "@/components/email/VerificationEmail"; import { db } from "@/db/drizzle"; import * as schema from "@/db/schema"; import { render } from "@react-email/render"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { emailOTP } from "better-auth/plugins"; import * as React from "react"; import { Resend } from "resend"; import { redis } from "./upstash"; const resend = new Resend(process.env.RESEND_API_KEY); export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema: { user: schema.user, account: schema.account, session: schema.session, verification: schema.verification, }, }), trustedOrigins: [ "http://localhost:3000", "https://dev.redacted.app", "https://stage.redacted.app", "https://redacted.app", ], cookies: { session: { name: "better-auth.session_token", options: { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", domain: process.env.NODE_ENV === "production" ? ".redacted.app" : undefined, }, }, }, rateLimit: { enabled: true, window: 60, // time window in seconds max: 50, // max requests in the window customRules: { "/sign-in/email-otp": { window: 30, max: 5, }, "/email-otp/send-verification-otp": { window: 60, max: 5, }, }, storage: "secondary-storage", }, secondaryStorage: { get: async (key) => { const value = await redis.get(key); return value ? String(value) : null; }, set: async (key, value, ttl) => { if (ttl) { await redis.set(key, value, { ex: ttl }); } else { await redis.set(key, value); } }, delete: async (key) => { await redis.del(key); }, }, plugins: [ emailOTP({ //Sends an email through resend }), ], }); ```
Author
Owner

@btnalexandre commented on GitHub (Mar 20, 2025):

Glad that helped you 🙂

I can't reproduce this in my scenario, it's seems like that could be occurred by the database (upstash configuration) or resend.

When the 500 error occurs exactly ?

<!-- gh-comment-id:2741470771 --> @btnalexandre commented on GitHub (Mar 20, 2025): Glad that helped you 🙂 I can't reproduce this in my scenario, it's seems like that could be occurred by the database (upstash configuration) or resend. When the 500 error occurs exactly ?
Author
Owner

@dinogomez commented on GitHub (Mar 21, 2025):

@btnalexandre ,

Doesn't seem to be resend, my upstash config is all on default as well.

interface SecondaryStorage {
	get: (key: string) => Promise<string | null>;
	set: (key: string, value: string, ttl?: number) => Promise<void>;
	delete: (key: string) => Promise<void>;
}

export const auth = betterAuth({
	database: drizzleAdapter(db, {
		provider: "pg",
		schema: {
			user: schema.user,
			account: schema.account,
			session: schema.session,
			verification: schema.verification,
		},
	}),
	trustedOrigins: [
		"http://localhost:3000",
	],
	cookies: {
		session: {
			name: "better-auth.session_token",
			options: {
				httpOnly: true,
				secure: process.env.NODE_ENV === "production",
				sameSite: "lax",
				path: "/",
				domain:
					process.env.NODE_ENV === "production" ? ".redacted.app" : undefined,
			},
		},
	},
	rateLimit: {
		enabled: true,
                storage: "secondary-storage",
		window: 300,
		max: 10,
	},
	secondaryStorage: {
		get: async (key) => {
			const value = await redis.get<string>(key);
			return value ?? null;
		},
		set: async (key, value, ttl) => {
			if (ttl) await redis.set(key, value, { ex: ttl });
			else await redis.set(key, value);
		},
		delete: async (key) => {
			await redis.del(key);
		},
	} satisfies SecondaryStorage,

I still get the same error , even without the custom rules.

On server restart when it gets get-session from the middleware to check if a session present /get-session or when sending an email otp and verifying it /email-top , /email-otp/send-verification-otp

These are the kv in Upstash

{
  "::1/email-otp/send-verification-otp": {
    "key": "::1/email-otp/send-verification-otp",
    "count": 1,
    "lastRequest": 1742546116500
  },
  "::1/get-session": {
    "key": "::1/get-session",
    "count": 1,
    "lastRequest": 1742546110350
  },
  "::1/sign-in/email-otp": {
    "key": "::1/sign-in/email-otp",
    "count": 1,
    "lastRequest": 1742546131013
  }
}

I'll attach my middleware.ts as well.

import type { auth } from "@/lib/auth";
import { betterFetch } from "@better-fetch/fetch";
import { type NextRequest, NextResponse } from "next/server";

type Session = typeof auth.$Infer.Session;

export async function middleware(request: NextRequest) {
	const { data: session } = await betterFetch<Session>(
		"/api/auth/get-session",
		{
			baseURL: request.nextUrl.origin,
			headers: {
				cookie: request.headers.get("cookie") || "", 
			},
		},
	);

	if (!session && request.nextUrl.pathname !== "/") {
		return NextResponse.redirect(new URL("/", request.url));
	}

	if (request.nextUrl.pathname === "/" && session) {
		return NextResponse.redirect(new URL("/dashboard", request.url));
	}

	return NextResponse.next();
}

export const config = {
	matcher: ["/dashboard", "/"], 
};

Thanks for taking the time to help me out! 🙇‍♂️

<!-- gh-comment-id:2742697481 --> @dinogomez commented on GitHub (Mar 21, 2025): @btnalexandre , Doesn't seem to be resend, my upstash config is all on default as well. ```ts interface SecondaryStorage { get: (key: string) => Promise<string | null>; set: (key: string, value: string, ttl?: number) => Promise<void>; delete: (key: string) => Promise<void>; } export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema: { user: schema.user, account: schema.account, session: schema.session, verification: schema.verification, }, }), trustedOrigins: [ "http://localhost:3000", ], cookies: { session: { name: "better-auth.session_token", options: { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", domain: process.env.NODE_ENV === "production" ? ".redacted.app" : undefined, }, }, }, rateLimit: { enabled: true, storage: "secondary-storage", window: 300, max: 10, }, secondaryStorage: { get: async (key) => { const value = await redis.get<string>(key); return value ?? null; }, set: async (key, value, ttl) => { if (ttl) await redis.set(key, value, { ex: ttl }); else await redis.set(key, value); }, delete: async (key) => { await redis.del(key); }, } satisfies SecondaryStorage, ``` I still get the same error , even without the custom rules. On server restart when it gets get-session from the middleware to check if a session present `/get-session` or when sending an email otp and verifying it `/email-top` , `/email-otp/send-verification-otp` These are the kv in Upstash ```json { "::1/email-otp/send-verification-otp": { "key": "::1/email-otp/send-verification-otp", "count": 1, "lastRequest": 1742546116500 }, "::1/get-session": { "key": "::1/get-session", "count": 1, "lastRequest": 1742546110350 }, "::1/sign-in/email-otp": { "key": "::1/sign-in/email-otp", "count": 1, "lastRequest": 1742546131013 } } ``` I'll attach my `middleware.ts` as well. ```ts import type { auth } from "@/lib/auth"; import { betterFetch } from "@better-fetch/fetch"; import { type NextRequest, NextResponse } from "next/server"; type Session = typeof auth.$Infer.Session; export async function middleware(request: NextRequest) { const { data: session } = await betterFetch<Session>( "/api/auth/get-session", { baseURL: request.nextUrl.origin, headers: { cookie: request.headers.get("cookie") || "", }, }, ); if (!session && request.nextUrl.pathname !== "/") { return NextResponse.redirect(new URL("/", request.url)); } if (request.nextUrl.pathname === "/" && session) { return NextResponse.redirect(new URL("/dashboard", request.url)); } return NextResponse.next(); } export const config = { matcher: ["/dashboard", "/"], }; ``` Thanks for taking the time to help me out! 🙇‍♂️
Author
Owner

@Kinfe123 commented on GitHub (Apr 12, 2025):

@dinogomez is this still an issue ?

<!-- gh-comment-id:2798750142 --> @Kinfe123 commented on GitHub (Apr 12, 2025): @dinogomez is this still an issue ?
Author
Owner

@dinogomez commented on GitHub (Apr 12, 2025):

@dinogomez is this still an issue ?

Hi @Kinfe123 , its still is for some reason, I'm using upstash as my redis storage. One workaround I found was just using custom storage instead of secondary storage for it.

<!-- gh-comment-id:2798763068 --> @dinogomez commented on GitHub (Apr 12, 2025): > [@dinogomez](https://github.com/dinogomez) is this still an issue ? Hi @Kinfe123 , its still is for some reason, I'm using upstash as my redis storage. One workaround I found was just using custom storage instead of secondary storage for it.
Author
Owner

@vladshcherbin commented on GitHub (May 13, 2025):

Does anyone has a working example? Can't get it working, not with enabled, not with customRules.

Found out there's no IP on dev environment

<!-- gh-comment-id:2874911959 --> @vladshcherbin commented on GitHub (May 13, 2025): ~Does anyone has a working example? Can't get it working, not with `enabled`, not with `customRules`.~ Found out there's no IP on dev environment
Author
Owner

@nleborgne commented on GitHub (Jun 21, 2025):

@dinogomez is this still an issue ?

Hi @Kinfe123 , its still is for some reason, I'm using upstash as my redis storage. One workaround I found was just using custom storage instead of secondary storage for it.

Could you please share your workaround? I'm also using redis with upstash as my secondary database and I'm also hitting 500 errors when using sign-in with google

<!-- gh-comment-id:2993808132 --> @nleborgne commented on GitHub (Jun 21, 2025): > > [@dinogomez](https://github.com/dinogomez) is this still an issue ? > > Hi [@Kinfe123](https://github.com/Kinfe123) , its still is for some reason, I'm using upstash as my redis storage. One workaround I found was just using custom storage instead of secondary storage for it. Could you please share your workaround? I'm also using redis with upstash as my secondary database and I'm also hitting 500 errors when using sign-in with google
Author
Owner

@dinogomez commented on GitHub (Jun 22, 2025):

Hi @nleborgne ,

I just used custom storage together with upstash redis, I haven't worked with the project in a while. But with other projects It was still working.

 rateLimit: {
    enabled: true,
    window: 60,
    max: 30,
    customStorage: {
      get: async (key: string) => {
        const value = await redis.get(key);
        if (!value) {
          return undefined;
        }
        try {
          return value as RateLimit;
        } catch {
          return undefined;
        }
      },
      set: async (key: string, value: RateLimit) => {
        await redis.set(key, JSON.stringify(value) || '');
      },
    },
  },

Redis is defined from uptash

import { Redis } from '@upstash/redis';

if (!process.env.UPSTASH_REDIS_REST_URL) {
  throw new Error('UPSTASH_REDIS_REST_URL is not defined');
}

if (!process.env.UPSTASH_REDIS_REST_TOKEN) {
  throw new Error('UPSTASH_REDIS_REST_TOKEN is not defined');
}

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL,
  token: process.env.UPSTASH_REDIS_REST_TOKEN,
});

Heres my complete better-auth-server.ts file, with some infos redacted. Hope it helps

import VerificationEmail from '@/components/email/verification-email';
import { db } from '@/db/drizzle';
import * as schema from '@/db/schema';
import { render } from '@react-email/render';
import { type RateLimit, betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { nextCookies } from 'better-auth/next-js';
import { emailOTP } from 'better-auth/plugins';
import * as React from 'react';
import { log } from '../observability/log';
import { resend } from '../resend/resend';
import { redis } from '../upstash';

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema: {
      user: schema.user,
      account: schema.account,
      session: schema.session,
      verification: schema.verification,
    },
  }),
  trustedOrigins: [
      // MY ORIGINS
  ],
  cookies: {
    session: {
      name: 'better-auth.session_token',
      options: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'lax',
        path: '/',
        domain:
          process.env.NODE_ENV === 'production' ? 'REDACTED' : undefined,
      },
    },
  },
  rateLimit: {
    enabled: true,
    window: 60,
    max: 30,
    customStorage: {
      get: async (key: string) => {
        const value = await redis.get(key);
        if (!value) {
          return undefined;
        }
        try {
          return value as RateLimit;
        } catch {
          return undefined;
        }
      },
      set: async (key: string, value: RateLimit) => {
        await redis.set(key, JSON.stringify(value) || '');
      },
    },
  },
  plugins: [
    emailOTP({
      otpLength: 6,
      expiresIn: 300,
      async sendVerificationOTP({ email, otp, type }) {
        try {
          log.info('[DEBUG] Sending verification email:', {
            email,
            otp,
            type,
          });
          // Create the email component with proper props
          const emailComponent = React.createElement(VerificationEmail, {
            otp,
            type: type as 'email-verification' | 'sign-in',
          });

          // Render the component to HTML and handle the Promise
          const emailHtml = await Promise.resolve(render(emailComponent));
          const from = 'REDACTED <auth@REDACTED.app>';

          await resend.emails.send({
            from,
            to: email,
            subject:
              type === 'email-verification'
                ? 'Welcome to REDACTED! Verify your email'
                : 'Verify your email to sign in to REDACTED',
            html: emailHtml,
          });
          log.info('Verification Email Sent', {
            email,
          });
        } catch (error) {
          log.error('Error sending verification email:', {
            error,
            email,
          });
          throw new Error('Failed to send verification email');
        }
      },
    }),
    nextCookies(),
  ],
});

<!-- gh-comment-id:2994150726 --> @dinogomez commented on GitHub (Jun 22, 2025): Hi @nleborgne , I just used custom storage together with upstash redis, I haven't worked with the project in a while. But with other projects It was still working. ```ts rateLimit: { enabled: true, window: 60, max: 30, customStorage: { get: async (key: string) => { const value = await redis.get(key); if (!value) { return undefined; } try { return value as RateLimit; } catch { return undefined; } }, set: async (key: string, value: RateLimit) => { await redis.set(key, JSON.stringify(value) || ''); }, }, }, ``` Redis is defined from uptash ```ts import { Redis } from '@upstash/redis'; if (!process.env.UPSTASH_REDIS_REST_URL) { throw new Error('UPSTASH_REDIS_REST_URL is not defined'); } if (!process.env.UPSTASH_REDIS_REST_TOKEN) { throw new Error('UPSTASH_REDIS_REST_TOKEN is not defined'); } export const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN, }); ``` Heres my complete `better-auth-server.ts` file, with some infos redacted. Hope it helps ```ts import VerificationEmail from '@/components/email/verification-email'; import { db } from '@/db/drizzle'; import * as schema from '@/db/schema'; import { render } from '@react-email/render'; import { type RateLimit, betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { nextCookies } from 'better-auth/next-js'; import { emailOTP } from 'better-auth/plugins'; import * as React from 'react'; import { log } from '../observability/log'; import { resend } from '../resend/resend'; import { redis } from '../upstash'; export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: { user: schema.user, account: schema.account, session: schema.session, verification: schema.verification, }, }), trustedOrigins: [ // MY ORIGINS ], cookies: { session: { name: 'better-auth.session_token', options: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', domain: process.env.NODE_ENV === 'production' ? 'REDACTED' : undefined, }, }, }, rateLimit: { enabled: true, window: 60, max: 30, customStorage: { get: async (key: string) => { const value = await redis.get(key); if (!value) { return undefined; } try { return value as RateLimit; } catch { return undefined; } }, set: async (key: string, value: RateLimit) => { await redis.set(key, JSON.stringify(value) || ''); }, }, }, plugins: [ emailOTP({ otpLength: 6, expiresIn: 300, async sendVerificationOTP({ email, otp, type }) { try { log.info('[DEBUG] Sending verification email:', { email, otp, type, }); // Create the email component with proper props const emailComponent = React.createElement(VerificationEmail, { otp, type: type as 'email-verification' | 'sign-in', }); // Render the component to HTML and handle the Promise const emailHtml = await Promise.resolve(render(emailComponent)); const from = 'REDACTED <auth@REDACTED.app>'; await resend.emails.send({ from, to: email, subject: type === 'email-verification' ? 'Welcome to REDACTED! Verify your email' : 'Verify your email to sign in to REDACTED', html: emailHtml, }); log.info('Verification Email Sent', { email, }); } catch (error) { log.error('Error sending verification email:', { error, email, }); throw new Error('Failed to send verification email'); } }, }), nextCookies(), ], }); ```
Author
Owner

@zotodev commented on GitHub (Jul 21, 2025):

I have been getting below on my dev server console so far.
No IP address found for rate limiting

<!-- gh-comment-id:3096442919 --> @zotodev commented on GitHub (Jul 21, 2025): I have been getting below on my dev server console so far. **No IP address found for rate limiting**
Author
Owner

@himself65 commented on GitHub (Aug 4, 2025):

I have been getting below on my dev server console so far. No IP address found for rate limiting

What's your dev server framework?

<!-- gh-comment-id:3150069904 --> @himself65 commented on GitHub (Aug 4, 2025): > I have been getting below on my dev server console so far. **No IP address found for rate limiting** What's your dev server framework?
Author
Owner

@dosubot[bot] commented on GitHub (Nov 3, 2025):

Hi, @dinogomez. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary

  • You reported that rate limiting on the Email OTP endpoint in Better Auth v1.2.4 was not working correctly with Upstash Redis, causing 500 errors.
  • It was noted that rateLimit.enabled needed to be set to true to activate rate limiting.
  • You encountered JSON parsing errors with Upstash and shared a workaround using customStorage to properly serialize and deserialize rate limit data.
  • Other users mentioned missing IP addresses in development environments also impacted rate limiting behavior.
  • The issue was resolved by enabling rate limiting and implementing the custom storage solution you provided.

Next Steps

  • Please confirm if this issue is still relevant with the latest version of better-auth.
  • If it is, feel free to comment to keep the discussion open; otherwise, I will automatically close this issue in 7 days.

Thanks for your understanding and contribution!

<!-- gh-comment-id:3481387359 --> @dosubot[bot] commented on GitHub (Nov 3, 2025): Hi, @dinogomez. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary** - You reported that rate limiting on the Email OTP endpoint in Better Auth v1.2.4 was not working correctly with Upstash Redis, causing 500 errors. - It was noted that `rateLimit.enabled` needed to be set to true to activate rate limiting. - You encountered JSON parsing errors with Upstash and shared a workaround using `customStorage` to properly serialize and deserialize rate limit data. - Other users mentioned missing IP addresses in development environments also impacted rate limiting behavior. - The issue was resolved by enabling rate limiting and implementing the custom storage solution you provided. **Next Steps** - Please confirm if this issue is still relevant with the latest version of better-auth. - If it is, feel free to comment to keep the discussion open; otherwise, I will automatically close this issue in 7 days. Thanks for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#8964