[GH-ISSUE #3804] Debug captcha plugin behavior #27053

Closed
opened 2026-04-17 17:51:08 -05:00 by GiteaMirror · 9 comments
Owner

Originally created by @issam-seghir on GitHub (Aug 5, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3804

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Issue Summary

The better-auth captcha plugin's behavior cannot be properly debugged because fetchOptions.headers are not being passed from the client to the actual HTTP request. This prevents reCAPTCHA tokens from reaching the server for validation.

Environment

  • better-auth version: 1.3.4
  • reCAPTCHA provider: google-recaptcha
  • reCAPTCHA version: v3 Enterprise
  • Framework: Next.js 15

Problem Description

1. Client-Side Works Correctly

reCAPTCHA token generation works perfectly on the client side:

// Login Form (client component)
const { executeRecaptcha } = useReCaptcha();

const recaptchaToken = await executeRecaptcha("auth_signin");
console.log("🔒 Full reCAPTCHA token:", recaptchaToken);  // 05BFc.....

const signInResult = await signIn.magicLink(
    {
        email: email,
        name: email.split("@")[0],
        newUserCallbackURL: newUserCallbackUrl,
        callbackURL: finalCallbackUrl,
    },
    {
        onSuccess(ctx) {
            setIsLoading(false);
        },
        onError(ctx) {
            console.error("Resend sign-in error:", ctx.error.message);
            setIsLoading(false);
        },
        onRequest(context) {
            console.log("📤 Sending request:", context);
        },
        onResponse(context) {
            console.log("📥 Received response:", context);
        },
        fetchOptions: {
            headers: {
                "x-captcha-response": recaptchaToken,
                "x-captcha-user-remote-ip": userIP,
            }
        }
    },
);

2. Server-Side Missing Headers

Despite being configured in fetchOptions.headers, the captcha headers never reach the server:

🔍 [DEBUG] POST http://localhost:3000/api/auth/sign-in/magic-link
🔍 [DEBUG] All Headers: {
  accept: '*/*',
  'accept-encoding': 'gzip, deflate, br, zstd',
  'accept-language': 'en-US,en;q=0.9',
  connection: 'keep-alive',
  'content-length': '52',
  'content-type': 'application/json',
  # ... standard headers only ...
  'x-forwarded-for': '::1',
  'x-forwarded-host': 'localhost:3000',
  'x-forwarded-port': '3000',
  'x-forwarded-proto': 'http'
}
🔍 [DEBUG] Captcha headers check:
  - x-captcha-response: ❌ Missing
  - x-captcha-user-remote-ip: ❌ Missing

3. Client-Side Debugging Shows Headers Are Prepared

From onRequest callback:

{
  "fetchOptions": {
    "headers": {
      "x-captcha-response": "05BFc....",
      "x-captcha-user-remote-ip": "156.9..."
    }
  },
  "headers": {}
}

From onResponse callback:

{
  "response": {},
  "request": {
    "baseURL": "http://localhost:3000/api/auth",
    "credentials": "include",
    "method": "POST",
    "fetchOptions": {
      "headers": {
        "x-captcha-response": "05BFc...",
        "x-captcha-user-remote-ip": "156.9..."
      }
    },
    "body": "{\"email\":\"test@hotmail.com\",\"name\":\"test\"}",
    "url": "http://localhost:3000/api/auth/sign-in/magic-link",
    "headers": {},
    "signal": {}
  }
}

Current vs. Expected behavior

..

What version of Better Auth are you using?

1.3.4

Provide environment information

windows 10 
chrome 
nextjs 15

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

Documentation, Package

Auth config (if applicable)

export const auth = betterAuth({
    appName: siteConfig.appName,
    debug: isDev,
    database: prismaAdapter(prisma, {
        provider: "postgresql",
        debugLogs: isDev,
    }),
    session: {
        modelName: "Session", 
        storeSessionInDatabase: true,
        preserveSessionInDatabase: true,
        fields: {
            token: "sessionToken",
            expiresAt: "expires",
        },
        expiresIn: ms("30d") / 1000,
    },
    rateLimit: {
        enabled: true,
        window: 60, // 1 minute
        max: 100, // 100 requests per minute
        customRules: {
            "/example/path": {
                window: 10,
                max: 100,
            },
        },
        storage: "memory",
        modelName: "rateLimit",
    },
    socialProviders: {
        google: {
            prompt: "select_account", 
            clientId: ENV.GOOGLE_CLIENT_ID,
            clientSecret: ENV.GOOGLE_CLIENT_SECRET,
        },
    },
    hooks: {
        async before({ request, returnHeaders, headers, body, asResponse, query, use }) {
            if (request && isDev && request.url.includes("magic-link")) {
                console.log(`🔍 [DEBUG] ${request.method} ${request.url}`);
                console.log("🔍 [DEBUG] All Headers:", Object.fromEntries(request.headers.entries()));

                // Check for ALL possible captcha-related headers
                const possibleHeaders = [
                    "x-captcha-response",
                    "x-captcha-user-remote-ip"
                ];

                console.log("🔍 [DEBUG] Captcha headers check:");
                possibleHeaders.forEach(headerName => {
                    const value = request.headers.get(headerName);
                    console.log(`  - ${headerName}:`, value ? `✅ Present (${value.substring(0, 20)}...)` : "❌ Missing");
                });
            }
        },
    },
    plugins: [
        magicLink({
            sendMagicLink: async ({ email, token, url }, request) => {
                sendVerificationRequest({
                    identifier: email,
                    url,
                    token,
                    request: request,
                    expires: new Date(Date.now() + ms("5m")),
                });
            },
            expiresIn: ms("5m") / 1000, // 5 minutes (in seconds) for magic links
            storeToken: isDev ? "plain" : "hashed",
        }),
        captcha({
            provider: "google-recaptcha",
            secretKey: ENV.RECAPTCHA_SECRET_KEY!,
        }),
   
        nextCookies(),
    ],
   
  
    advanced: {
        useSecureCookies: process.env.NODE_ENV === "production",
    },

});

Additional context

The better-auth captcha plugin lacks debugging capabilities and fetchOptions.headers are not passed to actual HTTP requests, making reCAPTCHA integration debugging impossible.

Core Issues

  1. No Captcha Debug Features: Can't see /siteverify calls or validation results
  2. Poor Error Messages: Generic "Missing CAPTCHA response" without specifics
  3. No Server-Side Logging: Can't track captcha plugin behavior

Suggested Solutions

  1. Captcha plugin should provide comprehensive debugging output
  2. /siteverify calls should be logged in debug mode for example i want to debug siteverify respnse and Error code as mentioned in the doc
  3. Clear error messages with specific missing headers
  4. Debug endpoint to inspect captcha configuration

For example : Enhanced Captcha Plugin Debugging

Proposed API for captcha plugin:

captcha({
    provider: "google-recaptcha",
    secretKey: ENV.RECAPTCHA_SECRET_KEY,
    
    // ✅ Enable debugging features
    debug: true, // Enable captcha-specific logging
    
    // ✅ Log all captcha verification attempts
    onVerify: (result) => {
        console.log("🔒 Captcha verification:", {
            success: result.success,
            score: result.score,
            action: result.action,
            hostname: result.hostname,
            challenge_ts: result.challenge_ts,
            error_codes: result["error-codes"]
        });
    },
    
    // ✅ Log Google /siteverify API calls
    onSiteVerify: (request, response) => {
        console.log("🌐 /siteverify request:", {
            secret: "***hidden***",
            response: request.response.substring(0, 20) + "...",
            remoteip: request.remoteip
        });
        console.log("🌐 /siteverify response:", response);
    },
    
    // ✅ Log header detection
    onHeaderCheck: (headers, found) => {
        console.log("🔍 Header check:", {
            expected: ["x-captcha-response", "x-captcha-token"],
            found: found,
            allHeaders: Object.keys(headers)
        });
    },
    
    // ✅ Customize expected headers
    headerNames: {
        token: "x-captcha-response", // or "x-captcha-token"
        userIP: "x-captcha-user-remote-ip"
    }
})
Originally created by @issam-seghir on GitHub (Aug 5, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3804 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce ## Issue Summary The better-auth captcha plugin's behavior cannot be properly debugged because `fetchOptions.headers` are not being passed from the client to the actual HTTP request. This prevents reCAPTCHA tokens from reaching the server for validation. ## Environment - **better-auth version:** 1.3.4 - **reCAPTCHA provider:** google-recaptcha - **reCAPTCHA version:** v3 Enterprise - **Framework:** Next.js 15 ## Problem Description ### 1. Client-Side Works Correctly reCAPTCHA token generation works perfectly on the client side: ```typescript // Login Form (client component) const { executeRecaptcha } = useReCaptcha(); const recaptchaToken = await executeRecaptcha("auth_signin"); console.log("🔒 Full reCAPTCHA token:", recaptchaToken); // 05BFc..... const signInResult = await signIn.magicLink( { email: email, name: email.split("@")[0], newUserCallbackURL: newUserCallbackUrl, callbackURL: finalCallbackUrl, }, { onSuccess(ctx) { setIsLoading(false); }, onError(ctx) { console.error("Resend sign-in error:", ctx.error.message); setIsLoading(false); }, onRequest(context) { console.log("📤 Sending request:", context); }, onResponse(context) { console.log("📥 Received response:", context); }, fetchOptions: { headers: { "x-captcha-response": recaptchaToken, "x-captcha-user-remote-ip": userIP, } } }, ); ``` ### 2. Server-Side Missing Headers Despite being configured in `fetchOptions.headers`, the captcha headers never reach the server: ```bash 🔍 [DEBUG] POST http://localhost:3000/api/auth/sign-in/magic-link 🔍 [DEBUG] All Headers: { accept: '*/*', 'accept-encoding': 'gzip, deflate, br, zstd', 'accept-language': 'en-US,en;q=0.9', connection: 'keep-alive', 'content-length': '52', 'content-type': 'application/json', # ... standard headers only ... 'x-forwarded-for': '::1', 'x-forwarded-host': 'localhost:3000', 'x-forwarded-port': '3000', 'x-forwarded-proto': 'http' } 🔍 [DEBUG] Captcha headers check: - x-captcha-response: ❌ Missing - x-captcha-user-remote-ip: ❌ Missing ``` ### 3. Client-Side Debugging Shows Headers Are Prepared **From `onRequest` callback:** ```json { "fetchOptions": { "headers": { "x-captcha-response": "05BFc....", "x-captcha-user-remote-ip": "156.9..." } }, "headers": {} } ``` **From `onResponse` callback:** ```json { "response": {}, "request": { "baseURL": "http://localhost:3000/api/auth", "credentials": "include", "method": "POST", "fetchOptions": { "headers": { "x-captcha-response": "05BFc...", "x-captcha-user-remote-ip": "156.9..." } }, "body": "{\"email\":\"test@hotmail.com\",\"name\":\"test\"}", "url": "http://localhost:3000/api/auth/sign-in/magic-link", "headers": {}, "signal": {} } } ``` ### Current vs. Expected behavior .. ### What version of Better Auth are you using? 1.3.4 ### Provide environment information ```bash windows 10 chrome nextjs 15 ``` ### Which area(s) are affected? (Select all that apply) Documentation, Package ### Auth config (if applicable) ```typescript export const auth = betterAuth({ appName: siteConfig.appName, debug: isDev, database: prismaAdapter(prisma, { provider: "postgresql", debugLogs: isDev, }), session: { modelName: "Session", storeSessionInDatabase: true, preserveSessionInDatabase: true, fields: { token: "sessionToken", expiresAt: "expires", }, expiresIn: ms("30d") / 1000, }, rateLimit: { enabled: true, window: 60, // 1 minute max: 100, // 100 requests per minute customRules: { "/example/path": { window: 10, max: 100, }, }, storage: "memory", modelName: "rateLimit", }, socialProviders: { google: { prompt: "select_account", clientId: ENV.GOOGLE_CLIENT_ID, clientSecret: ENV.GOOGLE_CLIENT_SECRET, }, }, hooks: { async before({ request, returnHeaders, headers, body, asResponse, query, use }) { if (request && isDev && request.url.includes("magic-link")) { console.log(`🔍 [DEBUG] ${request.method} ${request.url}`); console.log("🔍 [DEBUG] All Headers:", Object.fromEntries(request.headers.entries())); // Check for ALL possible captcha-related headers const possibleHeaders = [ "x-captcha-response", "x-captcha-user-remote-ip" ]; console.log("🔍 [DEBUG] Captcha headers check:"); possibleHeaders.forEach(headerName => { const value = request.headers.get(headerName); console.log(` - ${headerName}:`, value ? `✅ Present (${value.substring(0, 20)}...)` : "❌ Missing"); }); } }, }, plugins: [ magicLink({ sendMagicLink: async ({ email, token, url }, request) => { sendVerificationRequest({ identifier: email, url, token, request: request, expires: new Date(Date.now() + ms("5m")), }); }, expiresIn: ms("5m") / 1000, // 5 minutes (in seconds) for magic links storeToken: isDev ? "plain" : "hashed", }), captcha({ provider: "google-recaptcha", secretKey: ENV.RECAPTCHA_SECRET_KEY!, }), nextCookies(), ], advanced: { useSecureCookies: process.env.NODE_ENV === "production", }, }); ``` ### Additional context The better-auth captcha plugin lacks debugging capabilities and `fetchOptions.headers` are not passed to actual HTTP requests, making reCAPTCHA integration debugging impossible. ## Core Issues 1. **No Captcha Debug Features**: Can't see `/siteverify` calls or validation results 2. **Poor Error Messages**: Generic "Missing CAPTCHA response" without specifics 3. **No Server-Side Logging**: Can't track captcha plugin behavior ## Suggested Solutions 1. Captcha plugin should provide comprehensive debugging output 2. `/siteverify` calls should be logged in debug mode for example i want to debug siteverify respnse and Error code as mentioned in [the doc ](https://developers.google.com/recaptcha/docs/verify) 3. Clear error messages with specific missing headers 4. Debug endpoint to inspect captcha configuration ### For example : Enhanced Captcha Plugin Debugging **Proposed API for captcha plugin:** ```typescript captcha({ provider: "google-recaptcha", secretKey: ENV.RECAPTCHA_SECRET_KEY, // ✅ Enable debugging features debug: true, // Enable captcha-specific logging // ✅ Log all captcha verification attempts onVerify: (result) => { console.log("🔒 Captcha verification:", { success: result.success, score: result.score, action: result.action, hostname: result.hostname, challenge_ts: result.challenge_ts, error_codes: result["error-codes"] }); }, // ✅ Log Google /siteverify API calls onSiteVerify: (request, response) => { console.log("🌐 /siteverify request:", { secret: "***hidden***", response: request.response.substring(0, 20) + "...", remoteip: request.remoteip }); console.log("🌐 /siteverify response:", response); }, // ✅ Log header detection onHeaderCheck: (headers, found) => { console.log("🔍 Header check:", { expected: ["x-captcha-response", "x-captcha-token"], found: found, allHeaders: Object.keys(headers) }); }, // ✅ Customize expected headers headerNames: { token: "x-captcha-response", // or "x-captcha-token" userIP: "x-captcha-user-remote-ip" } }) ```
GiteaMirror added the locked label 2026-04-17 17:51:08 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Aug 5, 2025):

The root cause is that custom headers set in fetchOptions.headers on the client are not being forwarded in the actual HTTP request. This is likely due to how the better-auth client’s $fetch function merges and applies headers—if the logic fails to propagate fetchOptions.headers, the headers never reach the server, which matches your debug output.

For the captcha plugin to work, the server expects the reCAPTCHA token in the x-captcha-response header (not x-captcha-token), and the client IP in x-captcha-user-remote-ip [source] [source]. If these headers are missing, validation fails.

There have been similar issues with custom headers and query parameters not being forwarded in better-auth, sometimes requiring workarounds like custom fetch plugins or manual merging of options [source]. Double-check that you are using the correct header names and consider intercepting the request at the last moment (e.g., with a custom plugin or by patching $fetch) to ensure your headers are present in the outgoing request.

If you want to ensure compatibility, use x-captcha-response for the token and x-captcha-user-remote-ip for the IP address. If you still see missing headers, the issue is almost certainly in the client abstraction or how $fetch merges and applies fetchOptions.headers before sending the request.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

<!-- gh-comment-id:3155404486 --> @dosubot[bot] commented on GitHub (Aug 5, 2025): <!-- Answer --> The root cause is that custom headers set in `fetchOptions.headers` on the client are not being forwarded in the actual HTTP request. This is likely due to how the better-auth client’s `$fetch` function merges and applies headers—if the logic fails to propagate `fetchOptions.headers`, the headers never reach the server, which matches your debug output. For the captcha plugin to work, the server expects the reCAPTCHA token in the `x-captcha-response` header (not `x-captcha-token`), and the client IP in `x-captcha-user-remote-ip` [[source]](https://github.com/better-auth/better-auth/issues/3803) [[source]](https://github.com/better-auth/better-auth/blob/669ce3e6b737ce7c1a9ce982bf092f00d7373fbc/packages/better-auth/src/plugins/captcha/index.ts). If these headers are missing, validation fails. There have been similar issues with custom headers and query parameters not being forwarded in better-auth, sometimes requiring workarounds like custom fetch plugins or manual merging of options [[source]](https://github.com/better-auth/better-auth/issues/1500). Double-check that you are using the correct header names and consider intercepting the request at the last moment (e.g., with a custom plugin or by patching `$fetch`) to ensure your headers are present in the outgoing request. If you want to ensure compatibility, use `x-captcha-response` for the token and `x-captcha-user-remote-ip` for the IP address. If you still see missing headers, the issue is almost certainly in the client abstraction or how `$fetch` merges and applies `fetchOptions.headers` before sending the request. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/798180ce-5dd1-4ed0-957e-76b7fc509934?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/798180ce-5dd1-4ed0-957e-76b7fc509934?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/798180ce-5dd1-4ed0-957e-76b7fc509934?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/798180ce-5dd1-4ed0-957e-76b7fc509934?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/798180ce-5dd1-4ed0-957e-76b7fc509934?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/798180ce-5dd1-4ed0-957e-76b7fc509934?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/798180ce-5dd1-4ed0-957e-76b7fc509934?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3804)
Author
Owner

@issam-seghir commented on GitHub (Aug 5, 2025):

@dosu how to do that ? i update my issus with more suggestions

<!-- gh-comment-id:3155724895 --> @issam-seghir commented on GitHub (Aug 5, 2025): @dosu how to do that ? i update my issus with more suggestions
Author
Owner

@dosubot[bot] commented on GitHub (Aug 5, 2025):

To guarantee your custom headers (like x-captcha-response and x-captcha-user-remote-ip) are actually sent, you need to intercept the outgoing request at the fetch layer. In better-auth, the $fetch function is built using the external @better-fetch/fetch package, and all merging of fetchOptions.headers happens there—not in better-auth itself. If your headers are missing, it's likely because they aren't being merged or forwarded correctly by that package or by the way options are passed down [source].

Workaround:
You can inject or log headers using a custom fetch plugin. Here’s a minimal example:

const captchaHeaderPlugin = {
  id: "captcha-header-injector",
  name: "Captcha Header Injector",
  hooks: {
    onRequest(ctx) {
      ctx.request.headers.set("x-captcha-response", yourCaptchaToken);
      ctx.request.headers.set("x-captcha-user-remote-ip", yourUserIP);
      // Optional: log outgoing headers for debugging
      console.log("Outgoing headers:", Array.from(ctx.request.headers.entries()));
    }
  }
};

// Then add this plugin to your better-auth client config:
betterAuth({
  // ...other config,
  fetchOptions: {
    plugins: [captchaHeaderPlugin]
  }
});

This ensures the headers are present right before the request is sent. You can also check the browser’s network tab to confirm the headers are actually leaving the client.

About debugging the captcha plugin:
There are currently no built-in hooks like onVerify, onSiteVerify, or onHeaderCheck in the captcha plugin. If you want detailed logging of /siteverify calls or header checks, you’d need to fork or extend the plugin, or add logging in your own server-side hooks. The plugin system is the only extensibility point for manipulating requests or headers [source].

If you want to see exactly how headers are handled, check the code for $fetch and how plugins are merged in your version. If you still don’t see the headers, the issue is almost certainly in the fetch abstraction or how options are merged before sending the request.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

<!-- gh-comment-id:3155733965 --> @dosubot[bot] commented on GitHub (Aug 5, 2025): <!-- Answer --> To guarantee your custom headers (like `x-captcha-response` and `x-captcha-user-remote-ip`) are actually sent, you need to intercept the outgoing request at the fetch layer. In better-auth, the `$fetch` function is built using the external `@better-fetch/fetch` package, and all merging of `fetchOptions.headers` happens there—not in better-auth itself. If your headers are missing, it's likely because they aren't being merged or forwarded correctly by that package or by the way options are passed down [[source]](https://github.com/better-auth/better-auth/blob/669ce3e6b737ce7c1a9ce982bf092f00d7373fbc/packages/better-auth/src/client/config.ts). **Workaround:** You can inject or log headers using a custom fetch plugin. Here’s a minimal example: ```js const captchaHeaderPlugin = { id: "captcha-header-injector", name: "Captcha Header Injector", hooks: { onRequest(ctx) { ctx.request.headers.set("x-captcha-response", yourCaptchaToken); ctx.request.headers.set("x-captcha-user-remote-ip", yourUserIP); // Optional: log outgoing headers for debugging console.log("Outgoing headers:", Array.from(ctx.request.headers.entries())); } } }; // Then add this plugin to your better-auth client config: betterAuth({ // ...other config, fetchOptions: { plugins: [captchaHeaderPlugin] } }); ``` This ensures the headers are present right before the request is sent. You can also check the browser’s network tab to confirm the headers are actually leaving the client. **About debugging the captcha plugin:** There are currently no built-in hooks like `onVerify`, `onSiteVerify`, or `onHeaderCheck` in the captcha plugin. If you want detailed logging of `/siteverify` calls or header checks, you’d need to fork or extend the plugin, or add logging in your own server-side hooks. The plugin system is the only extensibility point for manipulating requests or headers [[source]](https://github.com/better-auth/better-auth/blob/669ce3e6b737ce7c1a9ce982bf092f00d7373fbc/packages/better-auth/src/client/config.ts). If you want to see exactly how headers are handled, check the code for `$fetch` and how plugins are merged in your version. If you still don’t see the headers, the issue is almost certainly in the fetch abstraction or how options are merged before sending the request. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/907db700-fdef-46cc-aec0-af345cefdeb8?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/907db700-fdef-46cc-aec0-af345cefdeb8?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/907db700-fdef-46cc-aec0-af345cefdeb8?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/907db700-fdef-46cc-aec0-af345cefdeb8?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/907db700-fdef-46cc-aec0-af345cefdeb8?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/907db700-fdef-46cc-aec0-af345cefdeb8?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/907db700-fdef-46cc-aec0-af345cefdeb8?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3804)
Author
Owner

@issam-seghir commented on GitHub (Aug 5, 2025):

@dosu in google admin console the statrus of captcha is "incompleted" and this is only for backend part so better auth isnt even verify the captcha and create an assessment!!
when i hover over backend integraiotns :
"It looks like you need to finish your integration, we haven't received any requests from your backend recently"

Image
<!-- gh-comment-id:3155868101 --> @issam-seghir commented on GitHub (Aug 5, 2025): @dosu in google admin console the statrus of captcha is "incompleted" and this is only for backend part so better auth isnt even verify the captcha and create an [assessment](https://cloud.google.com/recaptcha/docs/create-assessment-website)!! when i hover over backend integraiotns : "It looks like you need to finish your integration, we haven't received any requests from your backend recently" <img width="624" height="338" alt="Image" src="https://github.com/user-attachments/assets/00daf3a6-b30b-463c-8e55-b8bb6e80641c" />
Author
Owner

@frectonz commented on GitHub (Aug 13, 2025):

This is how you should pass in headers, the whole second parameter is the fetchOptions object.

const signInResult = await signIn.magicLink(
    {
        email: email,
        name: email.split("@")[0],
        newUserCallbackURL: newUserCallbackUrl,
        callbackURL: finalCallbackUrl,
    },
    {
        onSuccess(ctx) {
            setIsLoading(false);
        },
        onError(ctx) {
            console.error("Resend sign-in error:", ctx.error.message);
            setIsLoading(false);
        },
        onRequest(context) {
            console.log("📤 Sending request:", context);
        },
        onResponse(context) {
            console.log("📥 Received response:", context);
        },
        headers: {
            "x-captcha-response": recaptchaToken,
            "x-captcha-user-remote-ip": userIP,
        }
    },
);
<!-- gh-comment-id:3185144154 --> @frectonz commented on GitHub (Aug 13, 2025): This is how you should pass in headers, the whole second parameter is the `fetchOptions` object. ```typescript const signInResult = await signIn.magicLink( { email: email, name: email.split("@")[0], newUserCallbackURL: newUserCallbackUrl, callbackURL: finalCallbackUrl, }, { onSuccess(ctx) { setIsLoading(false); }, onError(ctx) { console.error("Resend sign-in error:", ctx.error.message); setIsLoading(false); }, onRequest(context) { console.log("📤 Sending request:", context); }, onResponse(context) { console.log("📥 Received response:", context); }, headers: { "x-captcha-response": recaptchaToken, "x-captcha-user-remote-ip": userIP, } }, ); ```
Author
Owner

@issam-seghir commented on GitHub (Aug 14, 2025):

@frectonz i pass everything to fetchOptions , but is still not working :

  const signInResult = await signIn.magicLink({
                email: email,
                name: email.split("@")[0],
                newUserCallbackURL: newUserCallbackUrl,
                callbackURL: finalCallbackUrl,
                errorCallbackURL: "/login",
                fetchOptions: {
                    headers: {
                        "x-captcha-response": recaptchaToken,
                        // "x-captcha-user-remote-ip": userIp, // optional: forwards the user's IP address to the captcha service
                    },
                    onSuccess(ctx) {
                        console.log("✅ [SUCCESS] Magic link sent successfully");
                        toast.success(t("Auth.EmailVerification.emailSent"), {
                            description: t("Auth.EmailVerification.newVerificationEmail"),
                        });
                        setIsLoading(false);
                    },
                    onError(ctx) {
                        console.error("❌ [ERROR] Magic link error:", ctx.error.message);
                        setIsLoading(false);
                    },
                    onRequest(context) {
                        console.log("📤 [REQUEST] Outgoing request:", {
                            url: context.url,
                            method: context.method || "POST",
                            hasHeaders: !!context. Headers,
                            headerKeys: context.headers ? Object.keys(context.headers) : "none",
                        });
                    },
                    onResponse(context) {
                        console.log("📥 [RESPONSE] Incoming response:", {
                            status: context.response?.status || "unknown",
                            ok: context.response?.ok || false,
                            url: context.response?.url || "unknown",
                        });
                    },
                },
            });
<!-- gh-comment-id:3187364234 --> @issam-seghir commented on GitHub (Aug 14, 2025): @frectonz i pass everything to fetchOptions , but is still not working : ```ts const signInResult = await signIn.magicLink({ email: email, name: email.split("@")[0], newUserCallbackURL: newUserCallbackUrl, callbackURL: finalCallbackUrl, errorCallbackURL: "/login", fetchOptions: { headers: { "x-captcha-response": recaptchaToken, // "x-captcha-user-remote-ip": userIp, // optional: forwards the user's IP address to the captcha service }, onSuccess(ctx) { console.log("✅ [SUCCESS] Magic link sent successfully"); toast.success(t("Auth.EmailVerification.emailSent"), { description: t("Auth.EmailVerification.newVerificationEmail"), }); setIsLoading(false); }, onError(ctx) { console.error("❌ [ERROR] Magic link error:", ctx.error.message); setIsLoading(false); }, onRequest(context) { console.log("📤 [REQUEST] Outgoing request:", { url: context.url, method: context.method || "POST", hasHeaders: !!context. Headers, headerKeys: context.headers ? Object.keys(context.headers) : "none", }); }, onResponse(context) { console.log("📥 [RESPONSE] Incoming response:", { status: context.response?.status || "unknown", ok: context.response?.ok || false, url: context.response?.url || "unknown", }); }, }, }); ```
Author
Owner

@issam-seghir commented on GitHub (Aug 16, 2025):

This issue should be reopened

<!-- gh-comment-id:3193964784 --> @issam-seghir commented on GitHub (Aug 16, 2025): This issue should be reopened
Author
Owner

@fabriziofeitosa commented on GitHub (Aug 28, 2025):

@issam-seghir try this:

const signInResult = await signIn.magicLink(
    {
        email: email,
        name: email.split("@")[0],
        newUserCallbackURL: newUserCallbackUrl,
        callbackURL: finalCallbackUrl,
        errorCallbackURL: "/login",
    },
    {
        headers: {
            "x-captcha-response": recaptchaToken,
        },
        onSuccess(ctx) {
            console.log("✅ [SUCCESS] Magic link sent successfully");
            toast.success(t("Auth.EmailVerification.emailSent"), {
                description: t("Auth.EmailVerification.newVerificationEmail"),
            });
            setIsLoading(false);
        },
        onError(ctx) {
            console.error("❌ [ERROR] Magic link error:", ctx.error.message);
            setIsLoading(false);
        },
        onRequest(context) {
            console.log("📤 [REQUEST] Outgoing request:", {
                url: context.url,
                method: context.method || "POST",
                hasHeaders: !!context. Headers,
                headerKeys: context.headers ? Object.keys(context.headers) : "none",
            });
        },
        onResponse(context) {
            console.log("📥 [RESPONSE] Incoming response:", {
                status: context.response?.status || "unknown",
                ok: context.response?.ok || false,
                url: context.response?.url || "unknown",
            });
        },
    },
);

It worked well here.

<!-- gh-comment-id:3231722156 --> @fabriziofeitosa commented on GitHub (Aug 28, 2025): @issam-seghir try this: ```js const signInResult = await signIn.magicLink( { email: email, name: email.split("@")[0], newUserCallbackURL: newUserCallbackUrl, callbackURL: finalCallbackUrl, errorCallbackURL: "/login", }, { headers: { "x-captcha-response": recaptchaToken, }, onSuccess(ctx) { console.log("✅ [SUCCESS] Magic link sent successfully"); toast.success(t("Auth.EmailVerification.emailSent"), { description: t("Auth.EmailVerification.newVerificationEmail"), }); setIsLoading(false); }, onError(ctx) { console.error("❌ [ERROR] Magic link error:", ctx.error.message); setIsLoading(false); }, onRequest(context) { console.log("📤 [REQUEST] Outgoing request:", { url: context.url, method: context.method || "POST", hasHeaders: !!context. Headers, headerKeys: context.headers ? Object.keys(context.headers) : "none", }); }, onResponse(context) { console.log("📥 [RESPONSE] Incoming response:", { status: context.response?.status || "unknown", ok: context.response?.ok || false, url: context.response?.url || "unknown", }); }, }, ); ``` It worked well here.
Author
Owner

@issam-seghir commented on GitHub (Aug 28, 2025):

@fabriziofeitosa My issue was that I didn't add the magic link endpoints to the CAPTCHA plugin's endpoint configuration, so CAPTCHA didn't send an assessment when the magic link endpoint was hit.
It's great that all plugins, especially the public ones like sign-in and sign-up, have CAPTCHA configurations, like rate-limiting feature where any plugin can add its endpoints to rate limiting.

<!-- gh-comment-id:3232699142 --> @issam-seghir commented on GitHub (Aug 28, 2025): @fabriziofeitosa My issue was that I didn't add the magic link endpoints to the CAPTCHA plugin's endpoint configuration, so CAPTCHA didn't send an assessment when the magic link endpoint was hit. It's great that all plugins, especially the public ones like sign-in and sign-up, have CAPTCHA configurations, like rate-limiting feature where any plugin can add its endpoints to rate limiting.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#27053