[GH-ISSUE #3805] Debug Captcha plugin #27054

Closed
opened 2026-04-17 17:51:13 -05:00 by GiteaMirror · 1 comment
Owner

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

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-token": 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-token: ❌ Missing
  - x-captcha-response: ❌ Missing
  - captcha-token: ❌ Missing
  - x-recaptcha-token: ❌ Missing
  - recaptcha-token: ❌ Missing
  - x-captcha-user-remote-ip: ❌ Missing

3. Client-Side Debugging Shows Headers Are Prepared

From onRequest callback:

{
  "fetchOptions": {
    "headers": {
      "x-captcha-token": "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-token": "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-token",
                    "x-captcha-response",
                    "captcha-token",
                    "x-recaptcha-token",
                    "recaptcha-token",
                    "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

Originally created by @issam-seghir on GitHub (Aug 5, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3805 ### 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-token": 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-token: ❌ Missing - x-captcha-response: ❌ Missing - captcha-token: ❌ Missing - x-recaptcha-token: ❌ Missing - recaptcha-token: ❌ Missing - x-captcha-user-remote-ip: ❌ Missing ``` ### 3. Client-Side Debugging Shows Headers Are Prepared **From `onRequest` callback:** ```json { "fetchOptions": { "headers": { "x-captcha-token": "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-token": "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-token", "x-captcha-response", "captcha-token", "x-recaptcha-token", "recaptcha-token", "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
GiteaMirror added the locked label 2026-04-17 17:51:13 -05:00
Author
Owner

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

For better-auth v1.3.4, the captcha plugin expects the reCAPTCHA token in the header x-captcha-response, not x-captcha-token. Try changing your fetchOptions.headers to use x-captcha-response and keep x-captcha-user-remote-ip for the IP address. Double-check in your browser’s Network tab that these headers are present in the outgoing request. If they are, but still missing on the server, review your CORS config—make sure both headers are included in allowedHeaders and exposedHeaders if you’re using custom CORS middleware, as missing CORS settings can prevent custom headers from being visible to the server or client [source].

If you see the headers in the browser but not on the server, try sending a plain fetch() request with the same headers to isolate whether the issue is with the better-auth client SDK or your integration. There’s no evidence in the codebase that the magicLink plugin strips custom headers, so the problem is likely in header naming or CORS setup [source].

Let me know if switching to x-captcha-response and reviewing CORS resolves it!

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:3155393468 --> @dosubot[bot] commented on GitHub (Aug 5, 2025): <!-- Answer --> For better-auth v1.3.4, the captcha plugin expects the reCAPTCHA token in the header x-captcha-response, not x-captcha-token. Try changing your fetchOptions.headers to use x-captcha-response and keep x-captcha-user-remote-ip for the IP address. Double-check in your browser’s Network tab that these headers are present in the outgoing request. If they are, but still missing on the server, review your CORS config—make sure both headers are included in allowedHeaders and exposedHeaders if you’re using custom CORS middleware, as missing CORS settings can prevent custom headers from being visible to the server or client [[source]](https://github.com/better-auth/better-auth/issues/3803). If you see the headers in the browser but not on the server, try sending a plain fetch() request with the same headers to isolate whether the issue is with the better-auth client SDK or your integration. There’s no evidence in the codebase that the magicLink plugin strips custom headers, so the problem is likely in header naming or CORS setup [[source]](https://github.com/better-auth/better-auth/blob/669ce3e6b737ce7c1a9ce982bf092f00d7373fbc/packages/better-auth/src/plugins/magic-link/index.ts). Let me know if switching to x-captcha-response and reviewing CORS resolves it! <!-- 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/540bc6f3-bed5-4d06-85d0-208f8c0ba139?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/540bc6f3-bed5-4d06-85d0-208f8c0ba139?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/540bc6f3-bed5-4d06-85d0-208f8c0ba139?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/540bc6f3-bed5-4d06-85d0-208f8c0ba139?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/540bc6f3-bed5-4d06-85d0-208f8c0ba139?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/540bc6f3-bed5-4d06-85d0-208f8c0ba139?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/540bc6f3-bed5-4d06-85d0-208f8c0ba139?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/3805)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#27054