[GH-ISSUE #7034] Cookies not set when using auth.api.signUpEmail() programmatically in NestJS #10708

Closed
opened 2026-04-13 07:00:31 -05:00 by GiteaMirror · 43 comments
Owner

Originally created by @iamibadd on GitHub (Dec 28, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7034

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

When I register the auth routes via BetterAuthModule.forRoot() and call the HTTP endpoint /auth/sign-in/email, cookies are correctly set in the response.

However, when I call the auth API programmatically using auth.api.signUpEmail(...), no cookies are set in the response—even when I pass returnHeaders: true and asResponse: true.

Current vs. Expected behavior

I expect the response to include the same Set-Cookie headers that are sent when hitting the HTTP endpoint directly.

What version of Better Auth are you using?

1.4.9

System info

N/A

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

Backend

Auth config (if applicable)

import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { emailOTP } from 'better-auth/plugins';
import { PrismaClient, UserStatus } from '@prisma/client';

import { SendEmailConfig, sendEmail } from '../utils/sendgrid';
import { EmailErrorMessages } from '../common/enums';
import { generateHash, validateHash } from '../utils/argon2/hashes';
import {
  OTP_EXPIRY_MINUTES,
  OTP_DIGITS,
  AUTH_SESSION_EXPIRY_TIME,
  AUTH_SESSION_UPDATE_AGE,
} from '../common/constants';

const prisma = new PrismaClient();

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: 'postgresql',
    transaction: true,
  }),
  advanced: {
    disableOriginCheck: process.env.NODE_ENV !== 'production',
    useSecureCookies: true,
  },
  secret: process.env.BETTER_AUTH_SECRET,
  session: {
    expiresIn: AUTH_SESSION_EXPIRY_TIME,
    updateAge: AUTH_SESSION_UPDATE_AGE,
  },
  basePath: `/${process.env.API_VERSION}/auth`,
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    password: {
      hash: async (password: string) => generateHash(password),
      verify: async ({ password, hash }: { password: string; hash: string }) =>
        validateHash(password, hash),
    },
  },
  user: {
    additionalFields: {
      accountTypeId: {
        type: 'string',
        required: true,
        input: true,
      },
      status: {
        type: [
          'email_verification',
          'email_verified',
          'pending_delete',
          'deleted',
        ],
        defaultValue: UserStatus.email_verification,
        input: false,
      },
      isDeleted: {
        type: 'boolean',
        defaultValue: false,
        input: false,
      },
    },
  },
  plugins: [
    emailOTP({
      overrideDefaultEmailVerification: true,
      sendVerificationOnSignUp: true,
      otpLength: OTP_DIGITS,
      expiresIn: OTP_EXPIRY_MINUTES,

      sendVerificationOTP: async ({ email, otp }) => {
        const sendgridOtpTemplateId = process.env.SENDGRID_OTP_TEMPLATE_ID;
        const mailConfig: SendEmailConfig = {
          templateId: sendgridOtpTemplateId || '',
          to: email,
          dynamicTemplateData: { otp: Number(otp) },
        };
        return sendEmail(mailConfig, EmailErrorMessages.SIGNUP_VERIFICATION);
      },
    }),
  ],
});

Additional context

No response

Originally created by @iamibadd on GitHub (Dec 28, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7034 ### Is this suited for github? - [ ] Yes, this is suited for github ### To Reproduce When I register the auth routes via `BetterAuthModule.forRoot()` and call the HTTP endpoint `/auth/sign-in/email`, cookies are correctly set in the response. However, when I call the auth API programmatically using `auth.api.signUpEmail(...)`, no cookies are set in the response—even when I pass `returnHeaders: true` and `asResponse: true`. ### Current vs. Expected behavior I expect the response to include the same Set-Cookie headers that are sent when hitting the HTTP endpoint directly. ### What version of Better Auth are you using? 1.4.9 ### System info ```bash N/A ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; import { emailOTP } from 'better-auth/plugins'; import { PrismaClient, UserStatus } from '@prisma/client'; import { SendEmailConfig, sendEmail } from '../utils/sendgrid'; import { EmailErrorMessages } from '../common/enums'; import { generateHash, validateHash } from '../utils/argon2/hashes'; import { OTP_EXPIRY_MINUTES, OTP_DIGITS, AUTH_SESSION_EXPIRY_TIME, AUTH_SESSION_UPDATE_AGE, } from '../common/constants'; const prisma = new PrismaClient(); export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql', transaction: true, }), advanced: { disableOriginCheck: process.env.NODE_ENV !== 'production', useSecureCookies: true, }, secret: process.env.BETTER_AUTH_SECRET, session: { expiresIn: AUTH_SESSION_EXPIRY_TIME, updateAge: AUTH_SESSION_UPDATE_AGE, }, basePath: `/${process.env.API_VERSION}/auth`, emailAndPassword: { enabled: true, requireEmailVerification: true, password: { hash: async (password: string) => generateHash(password), verify: async ({ password, hash }: { password: string; hash: string }) => validateHash(password, hash), }, }, user: { additionalFields: { accountTypeId: { type: 'string', required: true, input: true, }, status: { type: [ 'email_verification', 'email_verified', 'pending_delete', 'deleted', ], defaultValue: UserStatus.email_verification, input: false, }, isDeleted: { type: 'boolean', defaultValue: false, input: false, }, }, }, plugins: [ emailOTP({ overrideDefaultEmailVerification: true, sendVerificationOnSignUp: true, otpLength: OTP_DIGITS, expiresIn: OTP_EXPIRY_MINUTES, sendVerificationOTP: async ({ email, otp }) => { const sendgridOtpTemplateId = process.env.SENDGRID_OTP_TEMPLATE_ID; const mailConfig: SendEmailConfig = { templateId: sendgridOtpTemplateId || '', to: email, dynamicTemplateData: { otp: Number(otp) }, }; return sendEmail(mailConfig, EmailErrorMessages.SIGNUP_VERIFICATION); }, }), ], }); ``` ### Additional context _No response_
GiteaMirror added the locked label 2026-04-13 07:00:31 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Dec 28, 2025):

When using auth.api.signUpEmail(...) programmatically, it's important to note that cookies may not be set automatically as they would be when accessing the HTTP endpoint directly. This behavior occurs because server actions in some frameworks, like Next.js, require the use of specific cookie helpers to set cookies correctly.

To resolve this issue, you can integrate the nextCookies plugin into your configuration. This plugin allows cookies to be automatically set whenever a Set-Cookie header is present in the response. Here's how you can implement it:

  1. Ensure you have imported and included the nextCookies plugin in your auth setup, placing it as the last plugin in the array.

    import { nextCookies } from 'better-auth/next-js';
    
    // Your existing auth configuration
    export const auth = betterAuth({
      // ... your existing config
      plugins: [
        // other plugins,
        nextCookies(),
      ],
    });
    
  2. This configuration should help in setting cookies when auth.api.signUpEmail(...) is called.

If you're still encountering issues, you might want to double-check your environment setup, including any cross-domain cookie policies, if applicable.

Would you like assistance with any specific part of the integration or another question on Better Auth?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3694395438 --> @better-auth-agent[bot] commented on GitHub (Dec 28, 2025): When using `auth.api.signUpEmail(...)` programmatically, it's important to note that cookies may not be set automatically as they would be when accessing the HTTP endpoint directly. This behavior occurs because server actions in some frameworks, like Next.js, require the use of specific cookie helpers to set cookies correctly. To resolve this issue, you can integrate the `nextCookies` plugin into your configuration. This plugin allows cookies to be automatically set whenever a `Set-Cookie` header is present in the response. Here's how you can implement it: 1. Ensure you have imported and included the `nextCookies` plugin in your auth setup, placing it as the last plugin in the array. ```typescript import { nextCookies } from 'better-auth/next-js'; // Your existing auth configuration export const auth = betterAuth({ // ... your existing config plugins: [ // other plugins, nextCookies(), ], }); ``` 2. This configuration should help in setting cookies when `auth.api.signUpEmail(...)` is called. If you're still encountering issues, you might want to double-check your environment setup, including any cross-domain cookie policies, if applicable. Would you like assistance with any specific part of the integration or another question on Better Auth? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@iamibadd commented on GitHub (Dec 28, 2025):

@better-auth-agent, I'm using Nestjs, not Nextjs.

<!-- gh-comment-id:3694400861 --> @iamibadd commented on GitHub (Dec 28, 2025): @better-auth-agent, I'm using Nestjs, not Nextjs.
Author
Owner

@better-auth-agent[bot] commented on GitHub (Dec 28, 2025):

When using auth.api.signUpEmail(...) programmatically in NestJS, you may not see the expected Set-Cookie headers in the response. This could be due to how the API interacts with cookies in different contexts. It's essential to ensure that the server-side implementation allows for cookie handling correctly.

  1. Ensure Secure Cookies: If your server is running in a non-production environment, make sure that useSecureCookies is correctly set in your configuration, as secure cookies are typically only sent over HTTPS.

  2. Check Cookie Options: Consider verifying your cookie attributes, like sameSite settings, especially if you're making cross-site requests. This can prevent cookies from being set or sent back correctly.

  3. Use Return Headers: As you already mentioned that you're passing returnHeaders: true and asResponse: true, ensure you're handling these correctly when catching the response.

  4. Cross-Domain Considerations: If you need to share cookies across subdomains, be sure that the domain attribute for cookies is configured appropriately in your authentication settings.

If these steps don’t resolve the issue, it might be beneficial to tag the Better Auth team members, Beka (@bekacru) and Max (@ping__), for more targeted assistance.

How have you structured the calls to auth.api.signUpEmail(...), and are there any specific error messages you’re encountering?

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3694400973 --> @better-auth-agent[bot] commented on GitHub (Dec 28, 2025): When using `auth.api.signUpEmail(...)` programmatically in NestJS, you may not see the expected Set-Cookie headers in the response. This could be due to how the API interacts with cookies in different contexts. It's essential to ensure that the server-side implementation allows for cookie handling correctly. 1. **Ensure Secure Cookies**: If your server is running in a non-production environment, make sure that `useSecureCookies` is correctly set in your configuration, as secure cookies are typically only sent over HTTPS. 2. **Check Cookie Options**: Consider verifying your cookie attributes, like `sameSite` settings, especially if you're making cross-site requests. This can prevent cookies from being set or sent back correctly. 3. **Use Return Headers**: As you already mentioned that you're passing `returnHeaders: true` and `asResponse: true`, ensure you're handling these correctly when catching the response. 4. **Cross-Domain Considerations**: If you need to share cookies across subdomains, be sure that the domain attribute for cookies is configured appropriately in your authentication settings. If these steps don’t resolve the issue, it might be beneficial to tag the Better Auth team members, Beka (@bekacru) and Max (@ping__), for more targeted assistance. How have you structured the calls to `auth.api.signUpEmail(...)`, and are there any specific error messages you’re encountering? <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@iamibadd commented on GitHub (Dec 28, 2025):

@Bekacru @dosu could you please help

<!-- gh-comment-id:3694403726 --> @iamibadd commented on GitHub (Dec 28, 2025): @Bekacru @dosu could you please help
Author
Owner

@dosubot[bot] commented on GitHub (Dec 28, 2025):

Hi @iamibadd! I'm Dosu and I’m helping the better-auth team.

Here's what's happening: when you use auth.api.signUpEmail() programmatically in NestJS, Better Auth does generate the correct Set-Cookie headers internally, but NestJS doesn't automatically forward those cookies to the client. You need to manually extract the Set-Cookie header(s) from the response returned by Better Auth and set them on your outgoing NestJS HTTP response. This is a common integration detail across backend frameworks—it's not a bug, just how server-side APIs work with cookies [source].

Here's a pattern you can use in your NestJS controller or route handler:

const betterAuthResponse = await auth.api.signUpEmail({
  body: { /* your payload */ },
  asResponse: true,
});

// Extract Set-Cookie headers (can be multiple)
const setCookie = betterAuthResponse.headers.get('set-cookie');
if (setCookie) {
  // For Express-style response (NestJS default)
  res.setHeader('Set-Cookie', setCookie);
}

// Forward the rest of the response as needed
const payload = await betterAuthResponse.json();
res.status(betterAuthResponse.status).json(payload);

If there are multiple Set-Cookie headers (e.g., for session, cache, remember-me), you may need to split them and set each one individually. Always use the cookies generated by Better Auth—never manually construct them from raw tokens, as they need to be signed and encoded [source].

This approach ensures the browser receives and stores the session cookies, syncing authentication state as expected [source].

Let me know if you want a more detailed NestJS example or run into any edge cases!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3694406170 --> @dosubot[bot] commented on GitHub (Dec 28, 2025): <!-- Greeting --> Hi @iamibadd! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> Here's what's happening: when you use `auth.api.signUpEmail()` programmatically in NestJS, Better Auth does generate the correct `Set-Cookie` headers internally, but NestJS doesn't automatically forward those cookies to the client. You need to manually extract the `Set-Cookie` header(s) from the response returned by Better Auth and set them on your outgoing NestJS HTTP response. This is a common integration detail across backend frameworks—it's not a bug, just how server-side APIs work with cookies [[source](https://github.com/better-auth/better-auth/issues/4945)]. Here's a pattern you can use in your NestJS controller or route handler: ```typescript const betterAuthResponse = await auth.api.signUpEmail({ body: { /* your payload */ }, asResponse: true, }); // Extract Set-Cookie headers (can be multiple) const setCookie = betterAuthResponse.headers.get('set-cookie'); if (setCookie) { // For Express-style response (NestJS default) res.setHeader('Set-Cookie', setCookie); } // Forward the rest of the response as needed const payload = await betterAuthResponse.json(); res.status(betterAuthResponse.status).json(payload); ``` If there are multiple `Set-Cookie` headers (e.g., for session, cache, remember-me), you may need to split them and set each one individually. Always use the cookies generated by Better Auth—never manually construct them from raw tokens, as they need to be signed and encoded [[source](https://github.com/better-auth/better-auth/issues/4517)]. This approach ensures the browser receives and stores the session cookies, syncing authentication state as expected [[source](https://github.com/better-auth/better-auth/issues/6530)]. Let me know if you want a more detailed NestJS example or run into any edge cases! <!-- 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/8f0ebe90-985a-468b-8b7b-14a9dfb0e78b?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/8f0ebe90-985a-468b-8b7b-14a9dfb0e78b?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/8f0ebe90-985a-468b-8b7b-14a9dfb0e78b?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/8f0ebe90-985a-468b-8b7b-14a9dfb0e78b?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/8f0ebe90-985a-468b-8b7b-14a9dfb0e78b?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/8f0ebe90-985a-468b-8b7b-14a9dfb0e78b?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/8f0ebe90-985a-468b-8b7b-14a9dfb0e78b?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Here%27s%20what%27s%20happening%3A%20when%20you%20use%20%60auth.api.signUpEmail%28%29%60%20programmatically%20in%20NestJS%2C%20Better%20Auth%20does%20generate%20the%20correct%20%60Set-Cookie%60%20headers%20internally%2C%20but%20NestJS%20doesn%27t%20automatically%20forward%20those%20cookies%20to%20the%20client.%20You%20need%20to%20manually%20extract%20the%20%60Set-Cookie%60%20header%28s%29%20from%20the%20response%20returned%20by%20Better%20Auth%20and%20set%20them%20on%20your%20outgoing%20NestJS%20HTTP%20response.%20This%20is%20a%20common%20integration%20detail%20across%20backend%20frameworks%E2%80%94it%27s%20not%20a%20bug%2C%20just%20how%20server-side%20APIs%20work%20with%20cookies%20%5B%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/4945%29%5D.%0A%0AHere%27s%20a%20pattern%20you%20can%20use%20in%20your%20NestJS%20controller%20or%20route%20handler%3A%0A%0A%60%60%60typescript%0Aconst%20betterAuthResponse%20%3D%20await%20auth.api.signUpEmail%28%7B%0A%20%20body%3A%20%7B%20/%2A%20your%20payload%20%2A/%20%7D%2C%0A%20%20asResponse%3A%20true%2C%0A%7D%29%3B%0A%0A//%20Extract%20Set-Cookie%20headers%20%28can%20be%20multiple%29%0Aconst%20setCookie%20%3D%20betterAuthResponse.headers.get%28%27set-cookie%27%29%3B%0Aif%20%28setCookie%29%20%7B%0A%20%20//%20For%20Express-style%20response%20%28NestJS%20default%29%0A%20%20res.setHeader%28%27Set-Cookie%27%2C%20setCookie%29%3B%0A%7D%0A%0A//%20Forward%20the%20rest%20of%20the%20response%20as%20needed%0Aconst%20payload%20%3D%20await%20betterAuthResponse.json%28%29%3B%0Ares.status%28betterAuthResponse.status%29.json%28payload%29%3B%0A%60%60%60%0A%0AIf%20there%20are%20multiple%20%60Set-Cookie%60%20headers%20%28e.g.%2C%20for%20session%2C%20cache%2C%20remember-me%29%2C%20you%20may%20need%20to%20split%20them%20and%20set%20each%20one%20individually.%20Always%20use%20the%20cookies%20generated%20by%20Better%20Auth%E2%80%94never%20manually%20construct%20them%20from%20raw%20tokens%2C%20as%20they%20need%20to%20be%20signed%20and%20encoded%20%5B%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/4517%29%5D.%0A%0AThis%20approach%20ensures%20the%20browser%20receives%20and%20stores%20the%20session%20cookies%2C%20syncing%20authentication%20state%20as%20expected%20%5B%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/6530%29%5D.%0A%0ALet%20me%20know%20if%20you%20want%20a%20more%20detailed%20NestJS%20example%20or%20run%20into%20any%20edge%20cases%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Dec 28, 2025):

@dosu Below is an example of how I'm using it:

 @Post('sign-in/email')
  async login(@Body() body: LoginUserDto): Promise<BaseResponseDto<any>> {
    return this.authService.login(body);
  }
  async login(params: LoginUserDto): Promise<BaseResponseDto<any>> {
    const { email, password, rememberMe } = params;

    try {
      const data = await auth.api.signInEmail({
        body: { email, password, rememberMe },
        returnHeaders: true,
        asResponse: true,
      });

      return new BaseResponseDto({
        data,
      });
    } catch (error) {
      logger.error(`login error: ${error}`);
      throw new BadRequestException('Failed to login');
    }
  }
<!-- gh-comment-id:3694406631 --> @iamibadd commented on GitHub (Dec 28, 2025): @dosu Below is an example of how I'm using it: ``` @Post('sign-in/email') async login(@Body() body: LoginUserDto): Promise<BaseResponseDto<any>> { return this.authService.login(body); } ``` ``` async login(params: LoginUserDto): Promise<BaseResponseDto<any>> { const { email, password, rememberMe } = params; try { const data = await auth.api.signInEmail({ body: { email, password, rememberMe }, returnHeaders: true, asResponse: true, }); return new BaseResponseDto({ data, }); } catch (error) { logger.error(`login error: ${error}`); throw new BadRequestException('Failed to login'); } } ```
Author
Owner

@dosubot[bot] commented on GitHub (Dec 28, 2025):

The reason cookies aren't being set in your current NestJS flow is that returning the result from auth.api.signInEmail() doesn't forward the Set-Cookie headers to the client. You need to manually extract all Set-Cookie headers from the Better Auth response and set them on the outgoing NestJS HTTP response object—otherwise, the browser won't receive the session cookies and authentication won't work as expected (source).

Here's how you can update your controller to handle this:

import { Res, Body, Post, Controller } from '@nestjs/common';
import { Response } from 'express';

@Controller('auth')
export class AuthController {
  @Post('sign-in/email')
  async login(@Body() body: LoginUserDto, @Res() res: Response) {
    const betterAuthResponse = await auth.api.signInEmail({
      body,
      asResponse: true,
      returnHeaders: true,
    });

    // Collect all Set-Cookie headers (can be multiple)
    const cookies: string[] = [];
    betterAuthResponse.headers.forEach((value, key) => {
      if (key.toLowerCase() === 'set-cookie') {
        cookies.push(value);
      }
    });
    if (cookies.length) {
      res.setHeader('Set-Cookie', cookies);
    }

    const payload = await betterAuthResponse.json();
    res.status(betterAuthResponse.status).json(payload);
  }
}

This pattern ensures all cookies generated by Better Auth are sent to the browser (source). If you ever see multiple Set-Cookie headers (for session, cache, remember-me, etc.), always forward all of them—never manually construct cookies from raw tokens (source).

Let me know if you need help adapting this for your service layer or have edge cases like redirects or custom cookie handling!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3694408544 --> @dosubot[bot] commented on GitHub (Dec 28, 2025): <!-- Answer --> The reason cookies aren't being set in your current NestJS flow is that returning the result from `auth.api.signInEmail()` doesn't forward the Set-Cookie headers to the client. You need to manually extract all Set-Cookie headers from the Better Auth response and set them on the outgoing NestJS HTTP response object—otherwise, the browser won't receive the session cookies and authentication won't work as expected ([source](https://github.com/better-auth/better-auth/issues/4945)). Here's how you can update your controller to handle this: ```typescript import { Res, Body, Post, Controller } from '@nestjs/common'; import { Response } from 'express'; @Controller('auth') export class AuthController { @Post('sign-in/email') async login(@Body() body: LoginUserDto, @Res() res: Response) { const betterAuthResponse = await auth.api.signInEmail({ body, asResponse: true, returnHeaders: true, }); // Collect all Set-Cookie headers (can be multiple) const cookies: string[] = []; betterAuthResponse.headers.forEach((value, key) => { if (key.toLowerCase() === 'set-cookie') { cookies.push(value); } }); if (cookies.length) { res.setHeader('Set-Cookie', cookies); } const payload = await betterAuthResponse.json(); res.status(betterAuthResponse.status).json(payload); } } ``` This pattern ensures all cookies generated by Better Auth are sent to the browser ([source](https://github.com/better-auth/better-auth/issues/6530)). If you ever see multiple Set-Cookie headers (for session, cache, remember-me, etc.), always forward all of them—never manually construct cookies from raw tokens ([source](https://github.com/better-auth/better-auth/issues/4517)). Let me know if you need help adapting this for your service layer or have edge cases like redirects or custom cookie handling! <!-- 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/2f8837e5-ab88-49b2-8032-935b7da73e18?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/2f8837e5-ab88-49b2-8032-935b7da73e18?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/2f8837e5-ab88-49b2-8032-935b7da73e18?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/2f8837e5-ab88-49b2-8032-935b7da73e18?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/2f8837e5-ab88-49b2-8032-935b7da73e18?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/2f8837e5-ab88-49b2-8032-935b7da73e18?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/2f8837e5-ab88-49b2-8032-935b7da73e18?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=The%20reason%20cookies%20aren%27t%20being%20set%20in%20your%20current%20NestJS%20flow%20is%20that%20returning%20the%20result%20from%20%60auth.api.signInEmail%28%29%60%20doesn%27t%20forward%20the%20Set-Cookie%20headers%20to%20the%20client.%20You%20need%20to%20manually%20extract%20all%20Set-Cookie%20headers%20from%20the%20Better%20Auth%20response%20and%20set%20them%20on%20the%20outgoing%20NestJS%20HTTP%20response%20object%E2%80%94otherwise%2C%20the%20browser%20won%27t%20receive%20the%20session%20cookies%20and%20authentication%20won%27t%20work%20as%20expected%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/4945%29%29.%0A%0AHere%27s%20how%20you%20can%20update%20your%20controller%20to%20handle%20this%3A%0A%0A%60%60%60typescript%0Aimport%20%7B%20Res%2C%20Body%2C%20Post%2C%20Controller%20%7D%20from%20%27%40nestjs/common%27%3B%0Aimport%20%7B%20Response%20%7D%20from%20%27express%27%3B%0A%0A%40Controller%28%27auth%27%29%0Aexport%20class%20AuthController%20%7B%0A%20%20%40Post%28%27sign-in/email%27%29%0A%20%20async%20login%28%40Body%28%29%20body%3A%20LoginUserDto%2C%20%40Res%28%29%20res%3A%20Response%29%20%7B%0A%20%20%20%20const%20betterAuthResponse%20%3D%20await%20auth.api.signInEmail%28%7B%0A%20%20%20%20%20%20body%2C%0A%20%20%20%20%20%20asResponse%3A%20true%2C%0A%20%20%20%20%20%20returnHeaders%3A%20true%2C%0A%20%20%20%20%7D%29%3B%0A%0A%20%20%20%20//%20Collect%20all%20Set-Cookie%20headers%20%28can%20be%20multiple%29%0A%20%20%20%20const%20cookies%3A%20string%5B%5D%20%3D%20%5B%5D%3B%0A%20%20%20%20betterAuthResponse.headers.forEach%28%28value%2C%20key%29%20%3D%3E%20%7B%0A%20%20%20%20%20%20if%20%28key.toLowerCase%28%29%20%3D%3D%3D%20%27set-cookie%27%29%20%7B%0A%20%20%20%20%20%20%20%20cookies.push%28value%29%3B%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%29%3B%0A%20%20%20%20if%20%28cookies.length%29%20%7B%0A%20%20%20%20%20%20res.setHeader%28%27Set-Cookie%27%2C%20cookies%29%3B%0A%20%20%20%20%7D%0A%0A%20%20%20%20const%20payload%20%3D%20await%20betterAuthResponse.json%28%29%3B%0A%20%20%20%20res.status%28betterAuthResponse.status%29.json%28payload%29%3B%0A%20%20%7D%0A%7D%0A%60%60%60%0A%0AThis%20pattern%20ensures%20all%20cookies%20generated%20by%20Better%20Auth%20are%20sent%20to%20the%20browser%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/6530%29%29.%20If%20you%20ever%20see%20multiple%20Set-Cookie%20headers%20%28for%20session%2C%20cache%2C%20remember-me%2C%20etc.%29%2C%20always%20forward%20all%20of%20them%E2%80%94never%20manually%20construct%20cookies%20from%20raw%20tokens%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/4517%29%29.%20%0A%0ALet%20me%20know%20if%20you%20need%20help%20adapting%20this%20for%20your%20service%20layer%20or%20have%20edge%20cases%20like%20redirects%20or%20custom%20cookie%20handling%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Dec 28, 2025):

@dosu OT question — I was wondering whether it’s possible to apply rate limiting to auth.api methods.

When I register the auth routes via BetterAuthModule.forRoot() and call the HTTP endpoint /auth/sign-in/email, rate limiting is applied as expected.

However, when I call the auth API programmatically (e.g. auth.api.signUpEmail(...)) and wrap it inside a custom API, rate limiting doesn’t apply. I know the docs mention that this isn’t supported, but I was wondering if there’s any workaround or recommended approach to achieve this.

<!-- gh-comment-id:3695098663 --> @iamibadd commented on GitHub (Dec 28, 2025): @dosu OT question — I was wondering whether it’s possible to apply rate limiting to auth.api methods. When I register the auth routes via `BetterAuthModule.forRoot()` and call the HTTP endpoint `/auth/sign-in/email`, rate limiting is applied as expected. However, when I call the auth API programmatically (e.g. `auth.api.signUpEmail(...)`) and wrap it inside a custom API, rate limiting doesn’t apply. I know the docs mention that this isn’t supported, but I was wondering if there’s any workaround or recommended approach to achieve this.
Author
Owner

@dosubot[bot] commented on GitHub (Dec 28, 2025):

Rate limiting in Better Auth is only enforced at the HTTP layer—so when you call auth.api methods programmatically (like in a custom NestJS controller), you bypass the built-in rate limiter entirely. This is by design and confirmed by both maintainers and the codebase: rate limiting is tightly coupled to the HTTP request lifecycle and isn’t triggered by direct API calls ref ref.

There’s no built-in workaround, but the recommended approach is to apply rate limiting at your own HTTP route/controller level using NestJS features. The most common solution is to use the @nestjs/throttler package. For example:

import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { UseGuards, Controller, Post, Body, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller('auth')
@UseGuards(ThrottlerGuard)
export class AuthController {
  @Post('sign-in/email')
  @Throttle(5, 60) // 5 requests per 60 seconds per IP
  async login(@Body() body: LoginUserDto, @Res() res: Response) {
    // ...call auth.api.signInEmail as before
  }
}

This way, your custom API endpoints get rate limiting just like the built-in Better Auth HTTP endpoints. If you need more advanced logic (per-user, distributed, etc.), you can implement your own guard or middleware, or use a custom storage backend ref.

Let me know if you want a more detailed example or have a specific rate limiting scenario in mind!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3695100192 --> @dosubot[bot] commented on GitHub (Dec 28, 2025): <!-- Answer --> Rate limiting in Better Auth is only enforced at the HTTP layer—so when you call auth.api methods programmatically (like in a custom NestJS controller), you bypass the built-in rate limiter entirely. This is by design and confirmed by both maintainers and the codebase: rate limiting is tightly coupled to the HTTP request lifecycle and isn’t triggered by direct API calls [ref](https://github.com/better-auth/better-auth/issues/3264) [ref](https://github.com/better-auth/better-auth/issues/5875). There’s no built-in workaround, but the recommended approach is to apply rate limiting at your own HTTP route/controller level using NestJS features. The most common solution is to use the [@nestjs/throttler](https://docs.nestjs.com/security/rate-limiting) package. For example: ```typescript import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { UseGuards, Controller, Post, Body, Res } from '@nestjs/common'; import { Response } from 'express'; @Controller('auth') @UseGuards(ThrottlerGuard) export class AuthController { @Post('sign-in/email') @Throttle(5, 60) // 5 requests per 60 seconds per IP async login(@Body() body: LoginUserDto, @Res() res: Response) { // ...call auth.api.signInEmail as before } } ``` This way, your custom API endpoints get rate limiting just like the built-in Better Auth HTTP endpoints. If you need more advanced logic (per-user, distributed, etc.), you can implement your own guard or middleware, or use a custom storage backend [ref](https://github.com/better-auth/better-auth/issues/1410). Let me know if you want a more detailed example or have a specific rate limiting scenario in mind! <!-- 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/0a654cf3-0ad5-4281-b13c-2fdf154dc33f?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/0a654cf3-0ad5-4281-b13c-2fdf154dc33f?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/0a654cf3-0ad5-4281-b13c-2fdf154dc33f?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/0a654cf3-0ad5-4281-b13c-2fdf154dc33f?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/0a654cf3-0ad5-4281-b13c-2fdf154dc33f?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/0a654cf3-0ad5-4281-b13c-2fdf154dc33f?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/0a654cf3-0ad5-4281-b13c-2fdf154dc33f?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Rate%20limiting%20in%20Better%20Auth%20is%20only%20enforced%20at%20the%20HTTP%20layer%E2%80%94so%20when%20you%20call%20auth.api%20methods%20programmatically%20%28like%20in%20a%20custom%20NestJS%20controller%29%2C%20you%20bypass%20the%20built-in%20rate%20limiter%20entirely.%20This%20is%20by%20design%20and%20confirmed%20by%20both%20maintainers%20and%20the%20codebase%3A%20rate%20limiting%20is%20tightly%20coupled%20to%20the%20HTTP%20request%20lifecycle%20and%20isn%E2%80%99t%20triggered%20by%20direct%20API%20calls%C2%A0%5Bref%5D%28https%3A//github.com/better-auth/better-auth/issues/3264%29%C2%A0%5Bref%5D%28https%3A//github.com/better-auth/better-auth/issues/5875%29.%0A%0AThere%E2%80%99s%20no%20built-in%20workaround%2C%20but%20the%20recommended%20approach%20is%20to%20apply%20rate%20limiting%20at%20your%20own%20HTTP%20route/controller%20level%20using%20NestJS%20features.%20The%20most%20common%20solution%20is%20to%20use%20the%C2%A0%5B%40nestjs/throttler%5D%28https%3A//docs.nestjs.com/security/rate-limiting%29%C2%A0package.%20For%20example%3A%0A%0A%60%60%60typescript%0Aimport%20%7B%20Throttle%2C%20ThrottlerGuard%20%7D%20from%20%27%40nestjs/throttler%27%3B%0Aimport%20%7B%20UseGuards%2C%20Controller%2C%20Post%2C%20Body%2C%20Res%20%7D%20from%20%27%40nestjs/common%27%3B%0Aimport%20%7B%20Response%20%7D%20from%20%27express%27%3B%0A%0A%40Controller%28%27auth%27%29%0A%40UseGuards%28ThrottlerGuard%29%0Aexport%20class%20AuthController%20%7B%0A%20%20%40Post%28%27sign-in/email%27%29%0A%20%20%40Throttle%285%2C%2060%29%20//%205%20requests%20per%2060%20seconds%20per%20IP%0A%20%20async%20login%28%40Body%28%29%20body%3A%20LoginUserDto%2C%20%40Res%28%29%20res%3A%20Response%29%20%7B%0A%20%20%20%20//%20...call%20auth.api.signInEmail%20as%20before%0A%20%20%7D%0A%7D%0A%60%60%60%0A%0AThis%20way%2C%20your%20custom%20API%20endpoints%20get%20rate%20limiting%20just%20like%20the%20built-in%20Better%20Auth%20HTTP%20endpoints.%20If%20you%20need%20more%20advanced%20logic%20%28per-user%2C%20distributed%2C%20etc.%29%2C%20you%20can%20implement%20your%20own%20guard%20or%20middleware%2C%20or%20use%20a%20custom%20storage%20backend%C2%A0%5Bref%5D%28https%3A//github.com/better-auth/better-auth/issues/1410%29.%0A%0ALet%20me%20know%20if%20you%20want%20a%20more%20detailed%20example%20or%20have%20a%20specific%20rate%20limiting%20scenario%20in%20mind%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 1, 2026):

@dosu

I'm using the below in Nestjs

// auth.service.ts

  async googleSignin(): Promise<BaseResponseDto<any>> {
    try {
      const response = await auth.api.signInSocial({
        body: {
          provider: 'google',
          callbackURL: 'http://localhost:3000/dashboard',
          requestSignUp: true,
        },
      });
      return new BaseResponseDto({
        data: response,
      });
    } catch (error) {
      return handleError(error);
    }
  }

// auth.controller.ts

  @Get('google/signin')
  async googleSignin(): Promise<BaseResponseDto<any>> {
    return this.authService.googleSignin();
  }

I've added http://localhost:5001/v1/auth/callback/google in Google console Authorized redirect URIs.

When I hit /google/signin from the frontend, it returns 404 for /callback/google which is true because I don't have this endpoint in my Nestjs auth controller. My question is, do I need to create a /callback/google endpoint? If so, what do I get in response or how should I create this function? Do I need to use any other auth.api method?

Better auth config


export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET,
  database: prismaAdapter(prisma, {
    provider: 'postgresql',
    transaction: true,
  }),
  basePath: `/${process.env.API_VERSION}/auth`,
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    },
  },
});
<!-- gh-comment-id:3704136017 --> @iamibadd commented on GitHub (Jan 1, 2026): @dosu I'm using the below in Nestjs ``` // auth.service.ts async googleSignin(): Promise<BaseResponseDto<any>> { try { const response = await auth.api.signInSocial({ body: { provider: 'google', callbackURL: 'http://localhost:3000/dashboard', requestSignUp: true, }, }); return new BaseResponseDto({ data: response, }); } catch (error) { return handleError(error); } } // auth.controller.ts @Get('google/signin') async googleSignin(): Promise<BaseResponseDto<any>> { return this.authService.googleSignin(); } ``` I've added http://localhost:5001/v1/auth/callback/google in Google console Authorized redirect URIs. When I hit `/google/signin` from the frontend, it returns 404 for `/callback/google` which is true because I don't have this endpoint in my Nestjs auth controller. My question is, do I need to create a /callback/google endpoint? If so, what do I get in response or how should I create this function? Do I need to use any other auth.api method? Better auth config ``` export const auth = betterAuth({ secret: process.env.BETTER_AUTH_SECRET, database: prismaAdapter(prisma, { provider: 'postgresql', transaction: true, }), basePath: `/${process.env.API_VERSION}/auth`, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, }, });
Author
Owner

@dosubot[bot] commented on GitHub (Jan 1, 2026):

You do need a /callback/google endpoint, but you don't have to implement the OAuth logic yourself. When you use BetterAuthModule.forRoot() and mount the built-in router, Better Auth automatically registers /v1/auth/callback/google (or whatever your basePath is) and handles the entire OAuth callback flow: exchanging the code, fetching user info, creating the session, setting cookies, and redirecting to your callbackURL. You should not call any extra auth.api methods for the callback—just make sure requests to /v1/auth/callback/google are routed to the Better Auth handler, not your custom controller.

If you're getting a 404, it usually means the built-in router isn't mounted or requests aren't reaching it. Double-check that BetterAuthModule.forRoot() is registered in your NestJS app and that /v1/auth/callback/google is not shadowed by another route. You don't need to create a custom controller for the callback; just let the Better Auth router handle it (source).

If you want to use only custom controllers and not the built-in router (not recommended), you would have to replicate all the callback logic yourself, which is complex and error-prone.

In summary: let the built-in Better Auth router handle /v1/auth/callback/google, and you don't need to use any other auth.api method for the callback. Make sure your NestJS routing setup doesn't block or override this path (source).

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3704138771 --> @dosubot[bot] commented on GitHub (Jan 1, 2026): <!-- Answer --> You do need a /callback/google endpoint, but you don't have to implement the OAuth logic yourself. When you use BetterAuthModule.forRoot() and mount the built-in router, Better Auth automatically registers /v1/auth/callback/google (or whatever your basePath is) and handles the entire OAuth callback flow: exchanging the code, fetching user info, creating the session, setting cookies, and redirecting to your callbackURL. You should not call any extra auth.api methods for the callback—just make sure requests to /v1/auth/callback/google are routed to the Better Auth handler, not your custom controller. If you're getting a 404, it usually means the built-in router isn't mounted or requests aren't reaching it. Double-check that BetterAuthModule.forRoot() is registered in your NestJS app and that /v1/auth/callback/google is not shadowed by another route. You don't need to create a custom controller for the callback; just let the Better Auth router handle it ([source](https://github.com/better-auth/better-auth/blob/4bf85c648f0e964f91c5a795d1e3504e288ae47a/packages/better-auth/src/api/routes/callback.ts#L20-L265)). If you want to use only custom controllers and not the built-in router (not recommended), you would have to replicate all the callback logic yourself, which is complex and error-prone. In summary: let the built-in Better Auth router handle /v1/auth/callback/google, and you don't need to use any other auth.api method for the callback. Make sure your NestJS routing setup doesn't block or override this path ([source](https://github.com/better-auth/better-auth/issues/5478)). <!-- 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/9b8e96bb-751b-4e11-be70-e4b671e327b2?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/9b8e96bb-751b-4e11-be70-e4b671e327b2?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/9b8e96bb-751b-4e11-be70-e4b671e327b2?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/9b8e96bb-751b-4e11-be70-e4b671e327b2?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/9b8e96bb-751b-4e11-be70-e4b671e327b2?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/9b8e96bb-751b-4e11-be70-e4b671e327b2?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/9b8e96bb-751b-4e11-be70-e4b671e327b2?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%20do%20need%20a%20/callback/google%20endpoint%2C%20but%20you%20don%27t%20have%20to%20implement%20the%20OAuth%20logic%20yourself.%20When%20you%20use%20BetterAuthModule.forRoot%28%29%20and%20mount%20the%20built-in%20router%2C%20Better%20Auth%20automatically%20registers%20/v1/auth/callback/google%20%28or%20whatever%20your%20basePath%20is%29%20and%20handles%20the%20entire%20OAuth%20callback%20flow%3A%20exchanging%20the%20code%2C%20fetching%20user%20info%2C%20creating%20the%20session%2C%20setting%20cookies%2C%20and%20redirecting%20to%20your%20callbackURL.%20You%20should%20not%20call%20any%20extra%20auth.api%20methods%20for%20the%20callback%E2%80%94just%20make%20sure%20requests%20to%20/v1/auth/callback/google%20are%20routed%20to%20the%20Better%20Auth%20handler%2C%20not%20your%20custom%20controller.%0A%0AIf%20you%27re%20getting%20a%20404%2C%20it%20usually%20means%20the%20built-in%20router%20isn%27t%20mounted%20or%20requests%20aren%27t%20reaching%20it.%20Double-check%20that%20BetterAuthModule.forRoot%28%29%20is%20registered%20in%20your%20NestJS%20app%20and%20that%20/v1/auth/callback/google%20is%20not%20shadowed%20by%20another%20route.%20You%20don%27t%20need%20to%20create%20a%20custom%20controller%20for%20the%20callback%3B%20just%20let%20the%20Better%20Auth%20router%20handle%20it%C2%A0%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/blob/4bf85c648f0e964f91c5a795d1e3504e288ae47a/packages/better-auth/src/api/routes/callback.ts%23L20-L265%29%29.%0A%0AIf%20you%20want%20to%20use%20only%20custom%20controllers%20and%20not%20the%20built-in%20router%20%28not%20recommended%29%2C%20you%20would%20have%20to%20replicate%20all%20the%20callback%20logic%20yourself%2C%20which%20is%20complex%20and%20error-prone.%0A%0AIn%20summary%3A%20let%20the%20built-in%20Better%20Auth%20router%20handle%20/v1/auth/callback/google%2C%20and%20you%20don%27t%20need%20to%20use%20any%20other%20auth.api%20method%20for%20the%20callback.%20Make%20sure%20your%20NestJS%20routing%20setup%20doesn%27t%20block%20or%20override%20this%20path%C2%A0%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/5478%29%29.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 1, 2026):

@dosu

But I'm using auth.api methods and not the default behaviour of registering BetterAuthModule.forRoot(auth). So, if I need to keep both, can I do something like

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [appConfig],
      envFilePath: ['.env'],
    }),
    DatabaseModule,
    AuthModule, // it has my auth.api methods
    BetterAuthModule.forRoot(auth) // it points to the default flow.
})
<!-- gh-comment-id:3704146516 --> @iamibadd commented on GitHub (Jan 1, 2026): @dosu But I'm using auth.api methods and not the default behaviour of registering BetterAuthModule.forRoot(auth). So, if I need to keep both, can I do something like ``` @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [appConfig], envFilePath: ['.env'], }), DatabaseModule, AuthModule, // it has my auth.api methods BetterAuthModule.forRoot(auth) // it points to the default flow. }) ```
Author
Owner

@dosubot[bot] commented on GitHub (Jan 1, 2026):

Yes, you can import both your custom AuthModule (with controllers/services that use auth.api methods) and BetterAuthModule.forRoot(auth) in the same NestJS app. This lets you use the built-in Better Auth router for endpoints like /v1/auth/callback/google (which handles the full OAuth flow), while still exposing custom endpoints that use auth.api methods for other flows.

The main thing to watch out for is route conflicts: if both modules register the same HTTP path (for example, /v1/auth/sign-in/email), whichever handler is registered first will take precedence and the other will be shadowed. To avoid this, make sure your custom controllers use routes that don’t overlap with the built-in Better Auth endpoints.

Also, any middleware (like rate limiting, origin checks, or plugin hooks) attached to the BetterAuthModule routes will not run for requests handled by your custom controllers—you’ll need to add those protections yourself if you want them on your custom endpoints. The built-in router will handle all the standard auth endpoints, including social login callbacks, session management, etc. source

So your module structure is fine as long as you keep the routes distinct:

@Module({
  imports: [
    ConfigModule.forRoot({ /* ... */ }),
    DatabaseModule,
    AuthModule, // custom endpoints (e.g., /custom-auth/...)
    BetterAuthModule.forRoot(auth), // built-in endpoints (e.g., /v1/auth/...)
  ],
})
export class AppModule {}

If you want to see which endpoints the built-in router exposes, check the Better Auth docs or the codebase for the full list. If you run into a 404 on a callback or built-in endpoint, double-check that no custom controller is shadowing that path and that the BetterAuthModule is mounted at the correct basePath.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3704150413 --> @dosubot[bot] commented on GitHub (Jan 1, 2026): <!-- Answer --> Yes, you can import both your custom AuthModule (with controllers/services that use auth.api methods) and BetterAuthModule.forRoot(auth) in the same NestJS app. This lets you use the built-in Better Auth router for endpoints like `/v1/auth/callback/google` (which handles the full OAuth flow), while still exposing custom endpoints that use auth.api methods for other flows. The main thing to watch out for is route conflicts: if both modules register the same HTTP path (for example, `/v1/auth/sign-in/email`), whichever handler is registered first will take precedence and the other will be shadowed. To avoid this, make sure your custom controllers use routes that don’t overlap with the built-in Better Auth endpoints. Also, any middleware (like rate limiting, origin checks, or plugin hooks) attached to the BetterAuthModule routes will not run for requests handled by your custom controllers—you’ll need to add those protections yourself if you want them on your custom endpoints. The built-in router will handle all the standard auth endpoints, including social login callbacks, session management, etc. [source](https://github.com/better-auth/better-auth/blob/4bf85c648f0e964f91c5a795d1e3504e288ae47a/packages/better-auth/src/api/index.ts#L47-L370) So your module structure is fine as long as you keep the routes distinct: ```typescript @Module({ imports: [ ConfigModule.forRoot({ /* ... */ }), DatabaseModule, AuthModule, // custom endpoints (e.g., /custom-auth/...) BetterAuthModule.forRoot(auth), // built-in endpoints (e.g., /v1/auth/...) ], }) export class AppModule {} ``` If you want to see which endpoints the built-in router exposes, check the Better Auth docs or the codebase for the full list. If you run into a 404 on a callback or built-in endpoint, double-check that no custom controller is shadowing that path and that the BetterAuthModule is mounted at the correct basePath. <!-- 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/420545bd-95a4-4d1f-a464-f6ed2a2438c5?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/420545bd-95a4-4d1f-a464-f6ed2a2438c5?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/420545bd-95a4-4d1f-a464-f6ed2a2438c5?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/420545bd-95a4-4d1f-a464-f6ed2a2438c5?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/420545bd-95a4-4d1f-a464-f6ed2a2438c5?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/420545bd-95a4-4d1f-a464-f6ed2a2438c5?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/420545bd-95a4-4d1f-a464-f6ed2a2438c5?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Yes%2C%20you%20can%20import%20both%20your%20custom%20AuthModule%20%28with%20controllers/services%20that%20use%20auth.api%20methods%29%20and%20BetterAuthModule.forRoot%28auth%29%20in%20the%20same%20NestJS%20app.%20This%20lets%20you%20use%20the%20built-in%20Better%20Auth%20router%20for%20endpoints%20like%20%60/v1/auth/callback/google%60%20%28which%20handles%20the%20full%20OAuth%20flow%29%2C%20while%20still%20exposing%20custom%20endpoints%20that%20use%20auth.api%20methods%20for%20other%20flows.%0A%0AThe%20main%20thing%20to%20watch%20out%20for%20is%20route%20conflicts%3A%20if%20both%20modules%20register%20the%20same%20HTTP%20path%20%28for%20example%2C%20%60/v1/auth/sign-in/email%60%29%2C%20whichever%20handler%20is%20registered%20first%20will%20take%20precedence%20and%20the%20other%20will%20be%20shadowed.%20To%20avoid%20this%2C%20make%20sure%20your%20custom%20controllers%20use%20routes%20that%20don%E2%80%99t%20overlap%20with%20the%20built-in%20Better%20Auth%20endpoints.%0A%0AAlso%2C%20any%20middleware%20%28like%20rate%20limiting%2C%20origin%20checks%2C%20or%20plugin%20hooks%29%20attached%20to%20the%20BetterAuthModule%20routes%20will%20not%20run%20for%20requests%20handled%20by%20your%20custom%20controllers%E2%80%94you%E2%80%99ll%20need%20to%20add%20those%20protections%20yourself%20if%20you%20want%20them%20on%20your%20custom%20endpoints.%20The%20built-in%20router%20will%20handle%20all%20the%20standard%20auth%20endpoints%2C%20including%20social%20login%20callbacks%2C%20session%20management%2C%20etc.%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/blob/4bf85c648f0e964f91c5a795d1e3504e288ae47a/packages/better-auth/src/api/index.ts%23L47-L370%29%0A%0ASo%20your%20module%20structure%20is%20fine%20as%20long%20as%20you%20keep%20the%20routes%20distinct%3A%0A%0A%60%60%60typescript%0A%40Module%28%7B%0A%20%20imports%3A%20%5B%0A%20%20%20%20ConfigModule.forRoot%28%7B%20/%2A%20...%20%2A/%20%7D%29%2C%0A%20%20%20%20DatabaseModule%2C%0A%20%20%20%20AuthModule%2C%20//%20custom%20endpoints%20%28e.g.%2C%20/custom-auth/...%29%0A%20%20%20%20BetterAuthModule.forRoot%28auth%29%2C%20//%20built-in%20endpoints%20%28e.g.%2C%20/v1/auth/...%29%0A%20%20%5D%2C%0A%7D%29%0Aexport%20class%20AppModule%20%7B%7D%0A%60%60%60%0A%0AIf%20you%20want%20to%20see%20which%20endpoints%20the%20built-in%20router%20exposes%2C%20check%20the%20Better%20Auth%20docs%20or%20the%20codebase%20for%20the%20full%20list.%20If%20you%20run%20into%20a%20404%20on%20a%20callback%20or%20built-in%20endpoint%2C%20double-check%20that%20no%20custom%20controller%20is%20shadowing%20that%20path%20and%20that%20the%20BetterAuthModule%20is%20mounted%20at%20the%20correct%20basePath.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 1, 2026):

@dosu

Okay, so here is what I'm doing.

In Nestjs, I'm doing this

export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET,
  database: prismaAdapter(prisma, {
    provider: 'postgresql',
    transaction: true,
  }),
  basePath: `/${process.env.API_VERSION}/bauth`,
  socialProviders: {
    google: {
      prompt: 'select_account',
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    },
  },
  cookies: {
    state: {
      attributes: {
        sameSite: 'none',
        secure: true,
      },
    },
  },
  trustedOrigins: ['http://localhost:3000'],
});

I'm adding a custom path i.e. bauth instead of the default i.e. auth. I have added it in Google console Authorized redirect URIs as https://localhost:5001/v1/bauth/callback/google.

I'm registering, both my custom AuthModule and BetterAuthModule like below

@Module({
  imports: [
    ConfigModule.forRoot({ /* ... */ }),
    DatabaseModule,
    AuthModule, // custom endpoints (e.g., /custom-auth/...)
    BetterAuthModule.forRoot(auth), // built-in endpoints (e.g., /v1/auth/...)
  ],
})
export class AppModule {}

I'm sending the signin url from Nestjs to Nextjs frontend

// auth.service.ts

  async googleSignin(): Promise<BaseResponseDto<any>> {
    try {
      const response = await auth.api.signInSocial({
        body: {
          provider: 'google',
          callbackURL: 'http://localhost:3000/dashboard',
          requestSignUp: true,
        },
      });
      return new BaseResponseDto({
        data: response,
      });
    } catch (error) {
      return handleError(error);
    }
  }

// auth.controller.ts

  @Get('google/signin')
  async googleSignin(): Promise<BaseResponseDto<any>> {
    return this.authService.googleSignin();
  }

So, my redirect is happening correctly to /v1/bauth/callback/google but now I'm running into state_mismatch error.
Is my approach correct of initiating the OAuth from Nestjs or should I use Better Auth client in Nextjs frontend to initiate the OAuth process?

<!-- gh-comment-id:3704193567 --> @iamibadd commented on GitHub (Jan 1, 2026): @dosu Okay, so here is what I'm doing. In Nestjs, I'm doing this ``` export const auth = betterAuth({ secret: process.env.BETTER_AUTH_SECRET, database: prismaAdapter(prisma, { provider: 'postgresql', transaction: true, }), basePath: `/${process.env.API_VERSION}/bauth`, socialProviders: { google: { prompt: 'select_account', clientId: process.env.GOOGLE_CLIENT_ID as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, }, }, cookies: { state: { attributes: { sameSite: 'none', secure: true, }, }, }, trustedOrigins: ['http://localhost:3000'], }); ``` I'm adding a custom path i.e. `bauth` instead of the default i.e. `auth`. I have added it in Google console Authorized redirect URIs as `https://localhost:5001/v1/bauth/callback/google`. I'm registering, both my custom AuthModule and BetterAuthModule like below ``` @Module({ imports: [ ConfigModule.forRoot({ /* ... */ }), DatabaseModule, AuthModule, // custom endpoints (e.g., /custom-auth/...) BetterAuthModule.forRoot(auth), // built-in endpoints (e.g., /v1/auth/...) ], }) export class AppModule {} ``` I'm sending the signin url from Nestjs to Nextjs frontend ``` // auth.service.ts async googleSignin(): Promise<BaseResponseDto<any>> { try { const response = await auth.api.signInSocial({ body: { provider: 'google', callbackURL: 'http://localhost:3000/dashboard', requestSignUp: true, }, }); return new BaseResponseDto({ data: response, }); } catch (error) { return handleError(error); } } // auth.controller.ts @Get('google/signin') async googleSignin(): Promise<BaseResponseDto<any>> { return this.authService.googleSignin(); } ``` So, my redirect is happening correctly to `/v1/bauth/callback/google` but now I'm running into `state_mismatch` error. Is my approach correct of initiating the OAuth from Nestjs or should I use Better Auth client in Nextjs frontend to initiate the OAuth process?
Author
Owner

@dosubot[bot] commented on GitHub (Jan 1, 2026):

Your current approach—initiating the OAuth flow from your NestJS backend using auth.api.signInSocial and sending the URL to the frontend—causes the state_mismatch error because the OAuth state cookie (used for CSRF protection) is set in the backend response, but never actually reaches the browser. When the user is redirected to /v1/bauth/callback/google, the browser doesn't have the required state cookie, so Better Auth rejects the callback with a state_mismatch error (details, details, details).

The recommended and most reliable approach is to initiate the OAuth flow directly from your frontend (Next.js) using the Better Auth client (e.g., authClient.signIn.social). This ensures the state cookie is set in the browser, so the callback can validate it and complete the login flow without errors (details, details).

Migration path:

  • In your Next.js frontend, use the Better Auth client to start the social login (e.g., authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' })).
  • The browser will be redirected to the Google consent screen, then back to your backend's /v1/bauth/callback/google endpoint, which is handled by the built-in Better Auth router.
  • After authentication, the user is redirected to your specified callbackURL (e.g., /dashboard).

If you must initiate the flow from the backend, you would need to forward the Set-Cookie headers (containing the state cookie) from the backend response to the browser and ensure the browser stores them before redirecting to Google. This is fragile and not recommended for production (details).

Let me know if you want an example of how to trigger social login from the frontend or need help wiring up the Better Auth client in Next.js!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3704196249 --> @dosubot[bot] commented on GitHub (Jan 1, 2026): <!-- Answer --> Your current approach—initiating the OAuth flow from your NestJS backend using `auth.api.signInSocial` and sending the URL to the frontend—causes the `state_mismatch` error because the OAuth state cookie (used for CSRF protection) is set in the backend response, but never actually reaches the browser. When the user is redirected to `/v1/bauth/callback/google`, the browser doesn't have the required state cookie, so Better Auth rejects the callback with a `state_mismatch` error ([details](https://github.com/better-auth/better-auth/issues/7023), [details](https://github.com/better-auth/better-auth/issues/6483), [details](https://github.com/better-auth/better-auth/issues/6207)). The recommended and most reliable approach is to initiate the OAuth flow directly from your frontend (Next.js) using the Better Auth client (e.g., `authClient.signIn.social`). This ensures the state cookie is set in the browser, so the callback can validate it and complete the login flow without errors ([details](https://github.com/better-auth/better-auth/issues/5478), [details](https://github.com/better-auth/better-auth/issues/7023)). **Migration path:** - In your Next.js frontend, use the Better Auth client to start the social login (e.g., `authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' })`). - The browser will be redirected to the Google consent screen, then back to your backend's `/v1/bauth/callback/google` endpoint, which is handled by the built-in Better Auth router. - After authentication, the user is redirected to your specified `callbackURL` (e.g., `/dashboard`). If you must initiate the flow from the backend, you would need to forward the Set-Cookie headers (containing the state cookie) from the backend response to the browser and ensure the browser stores them before redirecting to Google. This is fragile and not recommended for production ([details](https://github.com/better-auth/better-auth/issues/7023)). Let me know if you want an example of how to trigger social login from the frontend or need help wiring up the Better Auth client in Next.js! <!-- 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/0061214e-775d-432a-88a9-e33461653855?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/0061214e-775d-432a-88a9-e33461653855?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/0061214e-775d-432a-88a9-e33461653855?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/0061214e-775d-432a-88a9-e33461653855?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/0061214e-775d-432a-88a9-e33461653855?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/0061214e-775d-432a-88a9-e33461653855?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/0061214e-775d-432a-88a9-e33461653855?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Your%20current%20approach%E2%80%94initiating%20the%20OAuth%20flow%20from%20your%20NestJS%20backend%20using%20%60auth.api.signInSocial%60%20and%20sending%20the%20URL%20to%20the%20frontend%E2%80%94causes%20the%20%60state_mismatch%60%20error%20because%20the%20OAuth%20state%20cookie%20%28used%20for%20CSRF%20protection%29%20is%20set%20in%20the%20backend%20response%2C%20but%20never%20actually%20reaches%20the%20browser.%20When%20the%20user%20is%20redirected%20to%20%60/v1/bauth/callback/google%60%2C%20the%20browser%20doesn%27t%20have%20the%20required%20state%20cookie%2C%20so%20Better%20Auth%20rejects%20the%20callback%20with%20a%20%60state_mismatch%60%20error%20%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/7023%29%2C%20%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/6483%29%2C%20%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/6207%29%29.%0A%0AThe%20recommended%20and%20most%20reliable%20approach%20is%20to%20initiate%20the%20OAuth%20flow%20directly%20from%20your%20frontend%20%28Next.js%29%20using%20the%20Better%20Auth%20client%20%28e.g.%2C%20%60authClient.signIn.social%60%29.%20This%20ensures%20the%20state%20cookie%20is%20set%20in%20the%20browser%2C%20so%20the%20callback%20can%20validate%20it%20and%20complete%20the%20login%20flow%20without%20errors%20%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/5478%29%2C%20%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/7023%29%29.%20%0A%0A%2A%2AMigration%20path%3A%2A%2A%20%20%0A-%20In%20your%20Next.js%20frontend%2C%20use%20the%20Better%20Auth%20client%20to%20start%20the%20social%20login%20%28e.g.%2C%20%60authClient.signIn.social%28%7B%20provider%3A%20%27google%27%2C%20callbackURL%3A%20%27/dashboard%27%20%7D%29%60%29.%0A-%20The%20browser%20will%20be%20redirected%20to%20the%20Google%20consent%20screen%2C%20then%20back%20to%20your%20backend%27s%20%60/v1/bauth/callback/google%60%20endpoint%2C%20which%20is%20handled%20by%20the%20built-in%20Better%20Auth%20router.%0A-%20After%20authentication%2C%20the%20user%20is%20redirected%20to%20your%20specified%20%60callbackURL%60%20%28e.g.%2C%20%60/dashboard%60%29.%0A%0AIf%20you%20must%20initiate%20the%20flow%20from%20the%20backend%2C%20you%20would%20need%20to%20forward%20the%20Set-Cookie%20headers%20%28containing%20the%20state%20cookie%29%20from%20the%20backend%20response%20to%20the%20browser%20and%20ensure%20the%20browser%20stores%20them%20before%20redirecting%20to%20Google.%20This%20is%20fragile%20and%20not%20recommended%20for%20production%20%28%5Bdetails%5D%28https%3A//github.com/better-auth/better-auth/issues/7023%29%29.%0A%0ALet%20me%20know%20if%20you%20want%20an%20example%20of%20how%20to%20trigger%20social%20login%20from%20the%20frontend%20or%20need%20help%20wiring%20up%20the%20Better%20Auth%20client%20in%20Next.js%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 1, 2026):

@dosu

Okay, so the OAuth login works now. However, I have a few custom fields in the User schema/model on the backend and they are required. So, after the signup is completed, my User creation fails with unable_to_create_user and reason is

Argument `accountType` is missing.
2026-01-01T23:23:57.128Z ERROR [Better Auth]: unable_to_create_user

How do we handle User creation with custom fields that are passed query/body params.

<!-- gh-comment-id:3704227945 --> @iamibadd commented on GitHub (Jan 1, 2026): @dosu Okay, so the OAuth login works now. However, I have a few custom fields in the User schema/model on the backend and they are required. So, after the signup is completed, my User creation fails with `unable_to_create_user` and reason is ``` Argument `accountType` is missing. 2026-01-01T23:23:57.128Z ERROR [Better Auth]: unable_to_create_user ``` How do we handle User creation with custom fields that are passed query/body params.
Author
Owner

@dosubot[bot] commented on GitHub (Jan 1, 2026):

This error happens because Better Auth only uses the data returned by the OAuth provider (like Google) when creating a new user, and does not automatically merge in custom fields (like accountType) from query/body params or your frontend. If your user schema requires fields that the provider doesn't supply, user creation will fail with unable_to_create_user ref ref.

The recommended way to handle required custom fields during social signup is to pass them in the OAuth state parameter when you initiate the login from your frontend. Then, use a Better Auth hook (like user.create.before) to extract those fields from the state and set them on the user during creation ref.

How to do it:

  1. When starting the OAuth flow from your frontend, include your custom fields in the state param:
    // Example in Next.js or any frontend
    const state = JSON.stringify({ accountType: "premium" });
    window.location.href = `/v1/bauth/sign-in/social/google?callbackURL=...&state=${encodeURIComponent(state)}`;
    
  2. In your Better Auth config, add a user.create.before hook to read the state and set the custom field:
    betterAuth({
      // ...your config
      hooks: {
        'user.create.before': async ({ data, context }) => {
          // Parse state from context if available
          const state = context?.oauth2?.state;
          if (state && state.accountType) {
            data.accountType = state.accountType;
          }
          // If not present, you may want to throw or set a default
          return data;
        }
      }
    });
    
  3. If you can't collect the required field up front, make it optional in your schema and prompt the user to complete their profile after signup ref.

Let me know if you want a more detailed example for your exact stack or have a specific custom field/flow in mind!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3704231699 --> @dosubot[bot] commented on GitHub (Jan 1, 2026): <!-- Answer --> This error happens because Better Auth only uses the data returned by the OAuth provider (like Google) when creating a new user, and does not automatically merge in custom fields (like `accountType`) from query/body params or your frontend. If your user schema requires fields that the provider doesn't supply, user creation will fail with `unable_to_create_user` [ref](https://github.com/better-auth/better-auth/issues/1025) [ref](https://github.com/better-auth/better-auth/issues/5770). The recommended way to handle required custom fields during social signup is to pass them in the OAuth `state` parameter when you initiate the login from your frontend. Then, use a Better Auth hook (like `user.create.before`) to extract those fields from the state and set them on the user during creation [ref](https://github.com/better-auth/better-auth/discussions/4239). **How to do it:** 1. When starting the OAuth flow from your frontend, include your custom fields in the `state` param: ```js // Example in Next.js or any frontend const state = JSON.stringify({ accountType: "premium" }); window.location.href = `/v1/bauth/sign-in/social/google?callbackURL=...&state=${encodeURIComponent(state)}`; ``` 2. In your Better Auth config, add a `user.create.before` hook to read the state and set the custom field: ```ts betterAuth({ // ...your config hooks: { 'user.create.before': async ({ data, context }) => { // Parse state from context if available const state = context?.oauth2?.state; if (state && state.accountType) { data.accountType = state.accountType; } // If not present, you may want to throw or set a default return data; } } }); ``` 3. If you can't collect the required field up front, make it optional in your schema and prompt the user to complete their profile after signup [ref](https://github.com/better-auth/better-auth/issues/5770). Let me know if you want a more detailed example for your exact stack or have a specific custom field/flow in mind! <!-- 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/0d887fc1-1ea6-4e8f-b522-df734c87d755?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/0d887fc1-1ea6-4e8f-b522-df734c87d755?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/0d887fc1-1ea6-4e8f-b522-df734c87d755?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/0d887fc1-1ea6-4e8f-b522-df734c87d755?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/0d887fc1-1ea6-4e8f-b522-df734c87d755?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/0d887fc1-1ea6-4e8f-b522-df734c87d755?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/0d887fc1-1ea6-4e8f-b522-df734c87d755?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20error%20happens%20because%20Better%20Auth%20only%20uses%20the%20data%20returned%20by%20the%20OAuth%20provider%20%28like%20Google%29%20when%20creating%20a%20new%20user%2C%20and%20does%20not%20automatically%20merge%20in%20custom%20fields%20%28like%20%60accountType%60%29%20from%20query/body%20params%20or%20your%20frontend.%20If%20your%20user%20schema%20requires%20fields%20that%20the%20provider%20doesn%27t%20supply%2C%20user%20creation%20will%20fail%20with%20%60unable_to_create_user%60%C2%A0%5Bref%5D%28https%3A//github.com/better-auth/better-auth/issues/1025%29%C2%A0%5Bref%5D%28https%3A//github.com/better-auth/better-auth/issues/5770%29.%0A%0AThe%20recommended%20way%20to%20handle%20required%20custom%20fields%20during%20social%20signup%20is%20to%20pass%20them%20in%20the%20OAuth%20%60state%60%20parameter%20when%20you%20initiate%20the%20login%20from%20your%20frontend.%20Then%2C%20use%20a%20Better%20Auth%20hook%20%28like%20%60user.create.before%60%29%20to%20extract%20those%20fields%20from%20the%20state%20and%20set%20them%20on%20the%20user%20during%20creation%C2%A0%5Bref%5D%28https%3A//github.com/better-auth/better-auth/discussions/4239%29.%0A%0A%2A%2AHow%20to%20do%20it%3A%2A%2A%0A%0A1.%20When%20starting%20the%20OAuth%20flow%20from%20your%20frontend%2C%20include%20your%20custom%20fields%20in%20the%20%60state%60%20param%3A%0A%20%20%20%60%60%60js%0A%20%20%20//%20Example%20in%20Next.js%20or%20any%20frontend%0A%20%20%20const%20state%20%3D%20JSON.stringify%28%7B%20accountType%3A%20%22premium%22%20%7D%29%3B%0A%20%20%20window.location.href%20%3D%20%60/v1/bauth/sign-in/social/google%3FcallbackURL%3D...%26state%3D%24%7BencodeURIComponent%28state%29%7D%60%3B%0A%20%20%20%60%60%60%0A2.%20In%20your%20Better%20Auth%20config%2C%20add%20a%20%60user.create.before%60%20hook%20to%20read%20the%20state%20and%20set%20the%20custom%20field%3A%0A%20%20%20%60%60%60ts%0A%20%20%20betterAuth%28%7B%0A%20%20%20%20%20//%20...your%20config%0A%20%20%20%20%20hooks%3A%20%7B%0A%20%20%20%20%20%20%20%27user.create.before%27%3A%20async%20%28%7B%20data%2C%20context%20%7D%29%20%3D%3E%20%7B%0A%20%20%20%20%20%20%20%20%20//%20Parse%20state%20from%20context%20if%20available%0A%20%20%20%20%20%20%20%20%20const%20state%20%3D%20context%3F.oauth2%3F.state%3B%0A%20%20%20%20%20%20%20%20%20if%20%28state%20%26%26%20state.accountType%29%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20data.accountType%20%3D%20state.accountType%3B%0A%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20//%20If%20not%20present%2C%20you%20may%20want%20to%20throw%20or%20set%20a%20default%0A%20%20%20%20%20%20%20%20%20return%20data%3B%0A%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%7D%0A%20%20%20%7D%29%3B%0A%20%20%20%60%60%60%0A3.%20If%20you%20can%27t%20collect%20the%20required%20field%20up%20front%2C%20make%20it%20optional%20in%20your%20schema%20and%20prompt%20the%20user%20to%20complete%20their%20profile%20after%20signup%C2%A0%5Bref%5D%28https%3A//github.com/better-auth/better-auth/issues/5770%29.%0A%0ALet%20me%20know%20if%20you%20want%20a%20more%20detailed%20example%20for%20your%20exact%20stack%20or%20have%20a%20specific%20custom%20field/flow%20in%20mind%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 1, 2026):

@dosu

I've figured it out. It needs to be done using databseHooks on the server side.

Server snippet:

betterAuth({
 // ...your config
  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          const modifiedUser = { ...user };

          if (ctx?.path === '/callback/:id') {
            const additionalData = await getOAuthState();

            if (additionalData?.accountType) {
              modifiedUser.accountTypeId = additionalData.accountType;
            }
          }

          return { data: modifiedUser };
        },
      },
    },
  },
});

Client snippet:

    await authClient.signIn.social({
      provider: 'google',
      callbackURL: 'http://localhost:3000/signin',
      additionalData: {
        accountType: 'account-type-value',
      },
    });
<!-- gh-comment-id:3704245975 --> @iamibadd commented on GitHub (Jan 1, 2026): @dosu I've figured it out. It needs to be done using `databseHooks` on the server side. Server snippet: ```JS betterAuth({ // ...your config databaseHooks: { user: { create: { before: async (user, ctx) => { const modifiedUser = { ...user }; if (ctx?.path === '/callback/:id') { const additionalData = await getOAuthState(); if (additionalData?.accountType) { modifiedUser.accountTypeId = additionalData.accountType; } } return { data: modifiedUser }; }, }, }, }, }); ``` Client snippet: ```JS await authClient.signIn.social({ provider: 'google', callbackURL: 'http://localhost:3000/signin', additionalData: { accountType: 'account-type-value', }, }); ```
Author
Owner

@dosubot[bot] commented on GitHub (Jan 1, 2026):

That's exactly the right approach and matches Better Auth's recommended pattern for handling required custom fields during social/OAuth signup. Passing additionalData from the client (which gets serialized into the OAuth state) and then extracting it server-side in a databaseHooks.user.create.before hook is both secure and flexible—especially when you assign sensitive fields like accountTypeId only in the hook, not directly from client input (reference).

A couple of best practices: always validate or sanitize any additionalData before assigning it, and if you want custom fields to appear in the session object on the client, you can use the customSession plugin to merge them in (reference). If you run into TypeScript type inference issues in your hooks, an explicit type cast can help (reference).

Let me know if you want an example for a more complex flow or have questions about session customization!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3704246687 --> @dosubot[bot] commented on GitHub (Jan 1, 2026): <!-- Answer --> That's exactly the right approach and matches Better Auth's recommended pattern for handling required custom fields during social/OAuth signup. Passing additionalData from the client (which gets serialized into the OAuth state) and then extracting it server-side in a databaseHooks.user.create.before hook is both secure and flexible—especially when you assign sensitive fields like accountTypeId only in the hook, not directly from client input ([reference](https://github.com/better-auth/better-auth/discussions/6399)). A couple of best practices: always validate or sanitize any additionalData before assigning it, and if you want custom fields to appear in the session object on the client, you can use the customSession plugin to merge them in ([reference](https://github.com/better-auth/better-auth/discussions/6399)). If you run into TypeScript type inference issues in your hooks, an explicit type cast can help ([reference](https://github.com/better-auth/better-auth/issues/6593)). Let me know if you want an example for a more complex flow or have questions about session customization! <!-- 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/84f0725c-1d89-4e95-9b2a-ccbe40940fb7?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/84f0725c-1d89-4e95-9b2a-ccbe40940fb7?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/84f0725c-1d89-4e95-9b2a-ccbe40940fb7?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/84f0725c-1d89-4e95-9b2a-ccbe40940fb7?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/84f0725c-1d89-4e95-9b2a-ccbe40940fb7?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/84f0725c-1d89-4e95-9b2a-ccbe40940fb7?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/84f0725c-1d89-4e95-9b2a-ccbe40940fb7?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=That%27s%20exactly%20the%20right%20approach%20and%20matches%20Better%20Auth%27s%20recommended%20pattern%20for%20handling%20required%20custom%20fields%20during%20social/OAuth%20signup.%20Passing%20additionalData%20from%20the%20client%20%28which%20gets%20serialized%20into%20the%20OAuth%20state%29%20and%20then%20extracting%20it%20server-side%20in%20a%20databaseHooks.user.create.before%20hook%20is%20both%20secure%20and%20flexible%E2%80%94especially%20when%20you%20assign%20sensitive%20fields%20like%20accountTypeId%20only%20in%20the%20hook%2C%20not%20directly%20from%20client%20input%20%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/discussions/6399%29%29.%0A%0AA%20couple%20of%20best%20practices%3A%20always%20validate%20or%20sanitize%20any%20additionalData%20before%20assigning%20it%2C%20and%20if%20you%20want%20custom%20fields%20to%20appear%20in%20the%20session%20object%20on%20the%20client%2C%20you%20can%20use%20the%20customSession%20plugin%20to%20merge%20them%20in%20%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/discussions/6399%29%29.%20If%20you%20run%20into%20TypeScript%20type%20inference%20issues%20in%20your%20hooks%2C%20an%20explicit%20type%20cast%20can%20help%20%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/6593%29%29.%0A%0ALet%20me%20know%20if%20you%20want%20an%20example%20for%20a%20more%20complex%20flow%20or%20have%20questions%20about%20session%20customization%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 8, 2026):

@dosu
I have added a database hook in my Nestjs auth config like below and I'm throwing some error on a condition.

  databaseHooks: {
    session: {
      create: {
        before: async (session) => {
          const userId = session.userId;
          const user = await prismaClient.user.findFirst({
            where: { id: userId },
            select: { status: true },
          });
          if (!user) return;
          if (user.status === UserStatus.pending_delete) {
            throw new APIError('BAD_REQUEST', {
              code: 'USER_PENDING_DELETION',
              message: 'User is added for pending deletion.',
            });
          }
        },
      },
    },
  },

In my Nextjs, I'm using authClient.signIn.social

  async function googleSignin() {
    setIsLoading(true);

    const authClient = createAuthClient({
      baseURL: 'http://localhost:5001/v1/bauth', // Nestjs server
    });

    try {
      await authClient.signIn.social({
        provider: 'google',
        callbackURL: 'http://localhost:3000/signin', // Nextjs server
        errorCallbackURL: 'http://localhost:3000/signin', // Nestjs server
        additionalData: {
          accountType: 'account-type-id',
        },
      });
    } catch (error) {
      console.log('error', error);
    }

    setIsLoading(false);
  }

Although, I'm not able to login and session is not created but the issue is, I'm not redirected to my Nextjs app, instead I see a call to my Nestjs server and its url in the browser with an error message. How can I handle the errors in Nextjs app?

PS: It takes me to http://localhost:5001/v1/bauth/callback/google?state=Pd1MoZz0-NX1oucT_y3j-nsLp39QyrkS&code=4%2F0ATX87lMn-zvWbJIcsjGJbTn0j3GV8E-eLKbLaR3hIYbsj4pFGxpfrPWBBXKaUABrmpWQpg&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&authuser=0&prompt=none

<!-- gh-comment-id:3721478637 --> @iamibadd commented on GitHub (Jan 8, 2026): @dosu I have added a database hook in my Nestjs auth config like below and I'm throwing some error on a condition. ```JS databaseHooks: { session: { create: { before: async (session) => { const userId = session.userId; const user = await prismaClient.user.findFirst({ where: { id: userId }, select: { status: true }, }); if (!user) return; if (user.status === UserStatus.pending_delete) { throw new APIError('BAD_REQUEST', { code: 'USER_PENDING_DELETION', message: 'User is added for pending deletion.', }); } }, }, }, }, ``` In my Nextjs, I'm using `authClient.signIn.social` ```JS async function googleSignin() { setIsLoading(true); const authClient = createAuthClient({ baseURL: 'http://localhost:5001/v1/bauth', // Nestjs server }); try { await authClient.signIn.social({ provider: 'google', callbackURL: 'http://localhost:3000/signin', // Nextjs server errorCallbackURL: 'http://localhost:3000/signin', // Nestjs server additionalData: { accountType: 'account-type-id', }, }); } catch (error) { console.log('error', error); } setIsLoading(false); } ``` Although, I'm not able to login and session is not created but the issue is, I'm not redirected to my Nextjs app, instead I see a call to my Nestjs server and its url in the browser with an error message. How can I handle the errors in Nextjs app? PS: It takes me to http://localhost:5001/v1/bauth/callback/google?state=Pd1MoZz0-NX1oucT_y3j-nsLp39QyrkS&code=4%2F0ATX87lMn-zvWbJIcsjGJbTn0j3GV8E-eLKbLaR3hIYbsj4pFGxpfrPWBBXKaUABrmpWQpg&scope=email+profile+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&authuser=0&prompt=none
Author
Owner

@dosubot[bot] commented on GitHub (Jan 8, 2026):

When you throw an error in a Better Auth database hook during the OAuth callback (like session.create.before), Better Auth should redirect the browser to your errorCallbackURL (e.g., http://localhost:3000/signin) with error details in the query string, such as ?error=USER_PENDING_DELETION&error_description=User+is+added+for+pending+deletion (callback route code).

If you’re seeing the error page on your backend instead of being redirected, double-check these points:

  • Your errorCallbackURL must be a trusted origin in your Better Auth config (trustedOrigins: ['http://localhost:3000']) (docs).
  • Pass errorCallbackURL as a full absolute URL when you call authClient.signIn.social from the frontend.
  • Make sure you’re not shadowing the callback route in NestJS with a custom controller.

On the Next.js side, handle errors by parsing the error and error_description query parameters in your /signin page and displaying a user-friendly message. For example:

// pages/signin.tsx (or /app/signin/page.tsx)
import { useRouter } from 'next/router';

export default function SignInPage() {
  const { query } = useRouter();
  const error = query.error;
  const errorDescription = query.error_description;

  return (
    <div>
      {error && (
        <div className="error">
          <strong>Error:</strong> {error}
          <br />
          {errorDescription}
        </div>
      )}
      {/* ...rest of your sign-in UI... */}
    </div>
  );
}

If you want even more control, you can use the onAPIError.errorURL option in your Better Auth config or add a hook for the /error route to always redirect to your frontend error page (discussion).

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3721489764 --> @dosubot[bot] commented on GitHub (Jan 8, 2026): <!-- Answer --> When you throw an error in a Better Auth database hook during the OAuth callback (like session.create.before), Better Auth should redirect the browser to your errorCallbackURL (e.g., `http://localhost:3000/signin`) with error details in the query string, such as `?error=USER_PENDING_DELETION&error_description=User+is+added+for+pending+deletion` ([callback route code](https://github.com/better-auth/better-auth/blob/c31c92f/packages/better-auth/src/api/routes/callback.ts#L11-L263)). If you’re seeing the error page on your backend instead of being redirected, double-check these points: - Your errorCallbackURL must be a trusted origin in your Better Auth config (`trustedOrigins: ['http://localhost:3000']`) ([docs](https://github.com/better-auth/better-auth/discussions/5253)). - Pass errorCallbackURL as a full absolute URL when you call `authClient.signIn.social` from the frontend. - Make sure you’re not shadowing the callback route in NestJS with a custom controller. On the Next.js side, handle errors by parsing the `error` and `error_description` query parameters in your `/signin` page and displaying a user-friendly message. For example: ```js // pages/signin.tsx (or /app/signin/page.tsx) import { useRouter } from 'next/router'; export default function SignInPage() { const { query } = useRouter(); const error = query.error; const errorDescription = query.error_description; return ( <div> {error && ( <div className="error"> <strong>Error:</strong> {error} <br /> {errorDescription} </div> )} {/* ...rest of your sign-in UI... */} </div> ); } ``` If you want even more control, you can use the `onAPIError.errorURL` option in your Better Auth config or add a hook for the `/error` route to always redirect to your frontend error page ([discussion](https://github.com/better-auth/better-auth/issues/1499)). <!-- 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/d517fdb1-c8ff-4029-9c7c-01d0cfa58471?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/d517fdb1-c8ff-4029-9c7c-01d0cfa58471?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/d517fdb1-c8ff-4029-9c7c-01d0cfa58471?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/d517fdb1-c8ff-4029-9c7c-01d0cfa58471?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/d517fdb1-c8ff-4029-9c7c-01d0cfa58471?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/d517fdb1-c8ff-4029-9c7c-01d0cfa58471?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/d517fdb1-c8ff-4029-9c7c-01d0cfa58471?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=When%20you%20throw%20an%20error%20in%20a%20Better%20Auth%20database%20hook%20during%20the%20OAuth%20callback%20%28like%20session.create.before%29%2C%20Better%20Auth%20should%20redirect%20the%20browser%20to%20your%20errorCallbackURL%20%28e.g.%2C%20%60http%3A//localhost%3A3000/signin%60%29%20with%20error%20details%20in%20the%20query%20string%2C%20such%20as%20%60%3Ferror%3DUSER_PENDING_DELETION%26error_description%3DUser%2Bis%2Badded%2Bfor%2Bpending%2Bdeletion%60%C2%A0%28%5Bcallback%20route%20code%5D%28https%3A//github.com/better-auth/better-auth/blob/c31c92f/packages/better-auth/src/api/routes/callback.ts%23L11-L263%29%29.%20%0A%0AIf%20you%E2%80%99re%20seeing%20the%20error%20page%20on%20your%20backend%20instead%20of%20being%20redirected%2C%20double-check%20these%20points%3A%0A-%20Your%20errorCallbackURL%20must%20be%20a%20trusted%20origin%20in%20your%20Better%20Auth%20config%20%28%60trustedOrigins%3A%20%5B%27http%3A//localhost%3A3000%27%5D%60%29%C2%A0%28%5Bdocs%5D%28https%3A//github.com/better-auth/better-auth/discussions/5253%29%29.%0A-%20Pass%20errorCallbackURL%20as%20a%20full%20absolute%20URL%20when%20you%20call%20%60authClient.signIn.social%60%20from%20the%20frontend.%0A-%20Make%20sure%20you%E2%80%99re%20not%20shadowing%20the%20callback%20route%20in%20NestJS%20with%20a%20custom%20controller.%0A%0AOn%20the%20Next.js%20side%2C%20handle%20errors%20by%20parsing%20the%20%60error%60%20and%20%60error_description%60%20query%20parameters%20in%20your%20%60/signin%60%20page%20and%20displaying%20a%20user-friendly%20message.%20For%20example%3A%0A%0A%60%60%60js%0A//%20pages/signin.tsx%20%28or%20/app/signin/page.tsx%29%0Aimport%20%7B%20useRouter%20%7D%20from%20%27next/router%27%3B%0A%0Aexport%20default%20function%20SignInPage%28%29%20%7B%0A%20%20const%20%7B%20query%20%7D%20%3D%20useRouter%28%29%3B%0A%20%20const%20error%20%3D%20query.error%3B%0A%20%20const%20errorDescription%20%3D%20query.error_description%3B%0A%0A%20%20return%20%28%0A%20%20%20%20%3Cdiv%3E%0A%20%20%20%20%20%20%7Berror%20%26%26%20%28%0A%20%20%20%20%20%20%20%20%3Cdiv%20className%3D%22error%22%3E%0A%20%20%20%20%20%20%20%20%20%20%3Cstrong%3EError%3A%3C/strong%3E%20%7Berror%7D%0A%20%20%20%20%20%20%20%20%20%20%3Cbr%20/%3E%0A%20%20%20%20%20%20%20%20%20%20%7BerrorDescription%7D%0A%20%20%20%20%20%20%20%20%3C/div%3E%0A%20%20%20%20%20%20%29%7D%0A%20%20%20%20%20%20%7B/%2A%20...rest%20of%20your%20sign-in%20UI...%20%2A/%7D%0A%20%20%20%20%3C/div%3E%0A%20%20%29%3B%0A%7D%0A%60%60%60%0A%0AIf%20you%20want%20even%20more%20control%2C%20you%20can%20use%20the%20%60onAPIError.errorURL%60%20option%20in%20your%20Better%20Auth%20config%20or%20add%20a%20hook%20for%20the%20%60/error%60%20route%20to%20always%20redirect%20to%20your%20frontend%20error%20page%C2%A0%28%5Bdiscussion%5D%28https%3A//github.com/better-auth/better-auth/issues/1499%29%29.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 8, 2026):

@dosu

I think the issue is that the Google OAuth callback completes successfully, and only after that the database session.create hook runs. At that point, throwing an error no longer affects the OAuth flow, so the user still gets redirected as if the OAuth step succeeded.

Is there a way to fail or abort the Google OAuth flow earlier, based on a custom condition (for example, user status), before the callback is treated as successful and the session is created?

With that said, I can use the ctx.redirect() method to redirect to the frontend app but I'm just curious if there's a better way.

<!-- gh-comment-id:3725635501 --> @iamibadd commented on GitHub (Jan 8, 2026): @dosu I think the issue is that the Google OAuth callback completes successfully, and only after that the database session.create hook runs. At that point, throwing an error no longer affects the OAuth flow, so the user still gets redirected as if the OAuth step succeeded. Is there a way to fail or abort the Google OAuth flow earlier, based on a custom condition (for example, user status), before the callback is treated as successful and the session is created? With that said, I can use the `ctx.redirect()` method to redirect to the frontend app but I'm just curious if there's a better way.
Author
Owner

@dosubot[bot] commented on GitHub (Jan 8, 2026):

You're right—throwing an error in the session.create hook is too late to abort the OAuth flow "early." At that point, the OAuth callback has already succeeded, and the user is treated as authenticated up to session creation. The recommended way to fail the flow earlier (for example, based on user status or other custom logic) is to perform your checks in the user.create.before hook instead. If you throw an error or return false in user.create.before, Better Auth will abort user creation and immediately redirect to your errorCallbackURL with error details, before any session is created or cookies are set (example, callback code).

Example:

databaseHooks: {
  user: {
    create: {
      before: async (user, ctx) => {
        // Custom validation: e.g., block users with certain email domains or statuses
        if (user.status === 'pending_delete') {
          throw new APIError('BAD_REQUEST', {
            code: 'USER_PENDING_DELETION',
            message: 'User is added for pending deletion.',
          });
        }
        // Or: return false to abort without a custom error
      },
    },
  },
}

This will stop the OAuth flow before any session is created, and the user will be redirected to your errorCallbackURL with the error code and message in the query string. There is currently no hook to abort the flow even earlier (before user creation), but user.create.before is the earliest reliable interception point for backend validation (reference).

Let me know if you want help with a more complex validation or need to handle errors differently!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3725656533 --> @dosubot[bot] commented on GitHub (Jan 8, 2026): <!-- Answer --> You're right—throwing an error in the session.create hook is too late to abort the OAuth flow "early." At that point, the OAuth callback has already succeeded, and the user is treated as authenticated up to session creation. The recommended way to fail the flow earlier (for example, based on user status or other custom logic) is to perform your checks in the user.create.before hook instead. If you throw an error or return false in user.create.before, Better Auth will abort user creation and immediately redirect to your errorCallbackURL with error details, before any session is created or cookies are set ([example](https://github.com/better-auth/better-auth/issues/5724), [callback code](https://github.com/better-auth/better-auth/blob/c31c92f3d24f3ccfb6776d4634e0681c6dee2db5/packages/better-auth/src/api/routes/callback.ts#L20-L265)). Example: ```js databaseHooks: { user: { create: { before: async (user, ctx) => { // Custom validation: e.g., block users with certain email domains or statuses if (user.status === 'pending_delete') { throw new APIError('BAD_REQUEST', { code: 'USER_PENDING_DELETION', message: 'User is added for pending deletion.', }); } // Or: return false to abort without a custom error }, }, }, } ``` This will stop the OAuth flow before any session is created, and the user will be redirected to your errorCallbackURL with the error code and message in the query string. There is currently no hook to abort the flow even earlier (before user creation), but user.create.before is the earliest reliable interception point for backend validation ([reference](https://github.com/better-auth/better-auth/issues/2742)). Let me know if you want help with a more complex validation or need to handle errors differently! <!-- 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/def03f16-0b6a-480f-82e4-992ca3615f8d?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/def03f16-0b6a-480f-82e4-992ca3615f8d?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/def03f16-0b6a-480f-82e4-992ca3615f8d?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/def03f16-0b6a-480f-82e4-992ca3615f8d?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/def03f16-0b6a-480f-82e4-992ca3615f8d?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/def03f16-0b6a-480f-82e4-992ca3615f8d?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/def03f16-0b6a-480f-82e4-992ca3615f8d?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%27re%20right%E2%80%94throwing%20an%20error%20in%20the%20session.create%20hook%20is%20too%20late%20to%20abort%20the%20OAuth%20flow%20%22early.%22%20At%20that%20point%2C%20the%20OAuth%20callback%20has%20already%20succeeded%2C%20and%20the%20user%20is%20treated%20as%20authenticated%20up%20to%20session%20creation.%20The%20recommended%20way%20to%20fail%20the%20flow%20earlier%20%28for%20example%2C%20based%20on%20user%20status%20or%20other%20custom%20logic%29%20is%20to%20perform%20your%20checks%20in%20the%20user.create.before%20hook%20instead.%20If%20you%20throw%20an%20error%20or%20return%20false%20in%20user.create.before%2C%20Better%20Auth%20will%20abort%20user%20creation%20and%20immediately%20redirect%20to%20your%20errorCallbackURL%20with%20error%20details%2C%20before%20any%20session%20is%20created%20or%20cookies%20are%20set%C2%A0%28%5Bexample%5D%28https%3A//github.com/better-auth/better-auth/issues/5724%29%2C%20%5Bcallback%20code%5D%28https%3A//github.com/better-auth/better-auth/blob/c31c92f3d24f3ccfb6776d4634e0681c6dee2db5/packages/better-auth/src/api/routes/callback.ts%23L20-L265%29%29.%0A%0AExample%3A%0A%0A%60%60%60js%0AdatabaseHooks%3A%20%7B%0A%20%20user%3A%20%7B%0A%20%20%20%20create%3A%20%7B%0A%20%20%20%20%20%20before%3A%20async%20%28user%2C%20ctx%29%20%3D%3E%20%7B%0A%20%20%20%20%20%20%20%20//%20Custom%20validation%3A%20e.g.%2C%20block%20users%20with%20certain%20email%20domains%20or%20statuses%0A%20%20%20%20%20%20%20%20if%20%28user.status%20%3D%3D%3D%20%27pending_delete%27%29%20%7B%0A%20%20%20%20%20%20%20%20%20%20throw%20new%20APIError%28%27BAD_REQUEST%27%2C%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20code%3A%20%27USER_PENDING_DELETION%27%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20message%3A%20%27User%20is%20added%20for%20pending%20deletion.%27%2C%0A%20%20%20%20%20%20%20%20%20%20%7D%29%3B%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20//%20Or%3A%20return%20false%20to%20abort%20without%20a%20custom%20error%0A%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%7D%2C%0A%20%20%7D%2C%0A%7D%0A%60%60%60%0A%0AThis%20will%20stop%20the%20OAuth%20flow%20before%20any%20session%20is%20created%2C%20and%20the%20user%20will%20be%20redirected%20to%20your%20errorCallbackURL%20with%20the%20error%20code%20and%20message%20in%20the%20query%20string.%20There%20is%20currently%20no%20hook%20to%20abort%20the%20flow%20even%20earlier%20%28before%20user%20creation%29%2C%20but%20user.create.before%20is%20the%20earliest%20reliable%20interception%20point%20for%20backend%20validation%C2%A0%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/2742%29%29.%0A%0ALet%20me%20know%20if%20you%20want%20help%20with%20a%20more%20complex%20validation%20or%20need%20to%20handle%20errors%20differently%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 8, 2026):

@dosu

My use case is different. If a user exists and the status is pending_delete, I don't want that user to signin. I am preventing it in the credentials provider before but for the Google OAuth, it seems like the callback runs successfully.

<!-- gh-comment-id:3725677213 --> @iamibadd commented on GitHub (Jan 8, 2026): @dosu My use case is different. If a user exists and the status is pending_delete, I don't want that user to signin. I am preventing it in the credentials provider before but for the Google OAuth, it seems like the callback runs successfully.
Author
Owner

@dosubot[bot] commented on GitHub (Jan 8, 2026):

You're right—blocking in session.create.before is too late for existing users in the OAuth flow. For Google OAuth, Better Auth first looks up the user, then immediately creates a session if the user exists, without running user.create hooks (those only run for new users). There’s currently no built-in hook to intercept and block sign-in for existing users based on custom status (like pending_delete) before the session is created and the callback succeeds (relevant issue).

The only reliable way to block these users is to patch Better Auth locally: after the user is found in handleOAuthUserInfo (see link-account.ts), add a check for user.status === 'pending_delete' and return an error object (which will trigger a redirect to your errorCallbackURL with error details). There’s no config option or hook for this yet.

If you want this to be supported out of the box, you could follow or upvote the feature request for a beforeCreateSession or beforeSignIn hook (see discussion). For now, a local patch or fork is the only way to block existing users with custom statuses during social sign-in.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3725700302 --> @dosubot[bot] commented on GitHub (Jan 8, 2026): <!-- Answer --> You're right—blocking in session.create.before is too late for existing users in the OAuth flow. For Google OAuth, Better Auth first looks up the user, then immediately creates a session if the user exists, without running user.create hooks (those only run for new users). There’s currently no built-in hook to intercept and block sign-in for existing users based on custom status (like pending_delete) before the session is created and the callback succeeds ([relevant issue](https://github.com/better-auth/better-auth/issues/2742)). The only reliable way to block these users is to patch Better Auth locally: after the user is found in handleOAuthUserInfo (see [link-account.ts](https://github.com/better-auth/better-auth/blob/c31c92f3d24f3ccfb6776d4634e0681c6dee2db5/packages/better-auth/src/oauth2/link-account.ts)), add a check for user.status === 'pending_delete' and return an error object (which will trigger a redirect to your errorCallbackURL with error details). There’s no config option or hook for this yet. If you want this to be supported out of the box, you could follow or upvote the feature request for a beforeCreateSession or beforeSignIn hook ([see discussion](https://github.com/better-auth/better-auth/issues/2742)). For now, a local patch or fork is the only way to block existing users with custom statuses during social sign-in. <!-- 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/0cfe4414-72bf-459b-8732-9f7e1ff06212?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/0cfe4414-72bf-459b-8732-9f7e1ff06212?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/0cfe4414-72bf-459b-8732-9f7e1ff06212?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/0cfe4414-72bf-459b-8732-9f7e1ff06212?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/0cfe4414-72bf-459b-8732-9f7e1ff06212?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/0cfe4414-72bf-459b-8732-9f7e1ff06212?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/0cfe4414-72bf-459b-8732-9f7e1ff06212?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%27re%20right%E2%80%94blocking%20in%20session.create.before%20is%20too%20late%20for%20existing%20users%20in%20the%20OAuth%20flow.%20For%20Google%20OAuth%2C%20Better%20Auth%20first%20looks%20up%20the%20user%2C%20then%20immediately%20creates%20a%20session%20if%20the%20user%20exists%2C%20without%20running%20user.create%20hooks%20%28those%20only%20run%20for%20new%20users%29.%20There%E2%80%99s%20currently%20no%20built-in%20hook%20to%20intercept%20and%20block%20sign-in%20for%20existing%20users%20based%20on%20custom%20status%20%28like%20pending_delete%29%20before%20the%20session%20is%20created%20and%20the%20callback%20succeeds%C2%A0%28%5Brelevant%20issue%5D%28https%3A//github.com/better-auth/better-auth/issues/2742%29%29.%0A%0AThe%20only%20reliable%20way%20to%20block%20these%20users%20is%20to%20patch%20Better%20Auth%20locally%3A%20after%20the%20user%20is%20found%20in%20handleOAuthUserInfo%20%28see%20%5Blink-account.ts%5D%28https%3A//github.com/better-auth/better-auth/blob/c31c92f3d24f3ccfb6776d4634e0681c6dee2db5/packages/better-auth/src/oauth2/link-account.ts%29%29%2C%20add%20a%20check%20for%20user.status%20%3D%3D%3D%20%27pending_delete%27%20and%20return%20an%20error%20object%20%28which%20will%20trigger%20a%20redirect%20to%20your%20errorCallbackURL%20with%20error%20details%29.%20There%E2%80%99s%20no%20config%20option%20or%20hook%20for%20this%20yet.%0A%0AIf%20you%20want%20this%20to%20be%20supported%20out%20of%20the%20box%2C%20you%20could%20follow%20or%20upvote%20the%20feature%20request%20for%20a%20beforeCreateSession%20or%20beforeSignIn%20hook%20%28%5Bsee%20discussion%5D%28https%3A//github.com/better-auth/better-auth/issues/2742%29%29.%20For%20now%2C%20a%20local%20patch%20or%20fork%20is%20the%20only%20way%20to%20block%20existing%20users%20with%20custom%20statuses%20during%20social%20sign-in.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 9, 2026):

@dosu

I have two questions:

  1. If I use credential i.e. email and password to create an account and my email is abc@gmail.com, I see that linkSocial method doesn't let me link my account with another email for example, abcd@gmail.com. Why is it so?
  2. If I signup with Google OAuth, can I link it with a credential account later on?
<!-- gh-comment-id:3731043290 --> @iamibadd commented on GitHub (Jan 9, 2026): @dosu I have two questions: 1) If I use credential i.e. email and password to create an account and my email is abc@gmail.com, I see that `linkSocial` method doesn't let me link my account with another email for example, abcd@gmail.com. Why is it so? 2) If I signup with Google OAuth, can I link it with a credential account later on?
Author
Owner

@dosubot[bot] commented on GitHub (Jan 10, 2026):

  1. The linkSocial method enforces that the email of the social account you’re linking must match the email of your existing (credential) account by default. This is a security and data integrity measure to prevent accidental account takeover or confusion—otherwise, someone could link a social account they control to a different user’s email/password account. If you want to allow linking accounts with different emails, you can set account.accountLinking.allowDifferentEmails = true in your Better Auth config, but this is off by default and comes with a warning about potential risks. Reference Reference Reference

  2. Yes, if you sign up with Google OAuth, you can later link a credential (email/password) login to the same user. The recommended way is to use the server-side setPassword API method to securely add a password to your existing OAuth user. Account linking is enabled by default, but you can further control it with the account.accountLinking config (e.g., trustedProviders, allowDifferentEmails). Reference Reference

If you want code samples or more details on either flow, let me know!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3731073653 --> @dosubot[bot] commented on GitHub (Jan 10, 2026): <!-- Answer --> 1) The linkSocial method enforces that the email of the social account you’re linking must match the email of your existing (credential) account by default. This is a security and data integrity measure to prevent accidental account takeover or confusion—otherwise, someone could link a social account they control to a different user’s email/password account. If you want to allow linking accounts with different emails, you can set `account.accountLinking.allowDifferentEmails = true` in your Better Auth config, but this is off by default and comes with a warning about potential risks. [Reference](https://github.com/better-auth/better-auth/blob/c31c92f3d24f3ccfb6776d4634e0681c6dee2db5/packages/better-auth/src/api/routes/account.ts#L103-L355) [Reference](https://github.com/better-auth/better-auth/issues/4610) [Reference](https://github.com/better-auth/better-auth/issues/4503) 2) Yes, if you sign up with Google OAuth, you can later link a credential (email/password) login to the same user. The recommended way is to use the server-side `setPassword` API method to securely add a password to your existing OAuth user. Account linking is enabled by default, but you can further control it with the `account.accountLinking` config (e.g., trustedProviders, allowDifferentEmails). [Reference](https://github.com/better-auth/better-auth/discussions/6385) [Reference](https://github.com/better-auth/better-auth/issues/7205) If you want code samples or more details on either flow, let me know! <!-- 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/2b8b2873-bfc7-49fd-8c7f-ec53cd1db505?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/2b8b2873-bfc7-49fd-8c7f-ec53cd1db505?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/2b8b2873-bfc7-49fd-8c7f-ec53cd1db505?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/2b8b2873-bfc7-49fd-8c7f-ec53cd1db505?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/2b8b2873-bfc7-49fd-8c7f-ec53cd1db505?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/2b8b2873-bfc7-49fd-8c7f-ec53cd1db505?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/2b8b2873-bfc7-49fd-8c7f-ec53cd1db505?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=1%29%20The%20linkSocial%20method%20enforces%20that%20the%20email%20of%20the%20social%20account%20you%E2%80%99re%20linking%20must%20match%20the%20email%20of%20your%20existing%20%28credential%29%20account%20by%20default.%20This%20is%20a%20security%20and%20data%20integrity%20measure%20to%20prevent%20accidental%20account%20takeover%20or%20confusion%E2%80%94otherwise%2C%20someone%20could%20link%20a%20social%20account%20they%20control%20to%20a%20different%20user%E2%80%99s%20email/password%20account.%20If%20you%20want%20to%20allow%20linking%20accounts%20with%20different%20emails%2C%20you%20can%20set%20%60account.accountLinking.allowDifferentEmails%20%3D%20true%60%20in%20your%20Better%20Auth%20config%2C%20but%20this%20is%20off%20by%20default%20and%20comes%20with%20a%20warning%20about%20potential%20risks.%20%5BReference%5D%28https%3A//github.com/better-auth/better-auth/blob/c31c92f3d24f3ccfb6776d4634e0681c6dee2db5/packages/better-auth/src/api/routes/account.ts%23L103-L355%29%20%5BReference%5D%28https%3A//github.com/better-auth/better-auth/issues/4610%29%20%5BReference%5D%28https%3A//github.com/better-auth/better-auth/issues/4503%29%0A%0A2%29%20Yes%2C%20if%20you%20sign%20up%20with%20Google%20OAuth%2C%20you%20can%20later%20link%20a%20credential%20%28email/password%29%20login%20to%20the%20same%20user.%20The%20recommended%20way%20is%20to%20use%20the%20server-side%20%60setPassword%60%20API%20method%20to%20securely%20add%20a%20password%20to%20your%20existing%20OAuth%20user.%20Account%20linking%20is%20enabled%20by%20default%2C%20but%20you%20can%20further%20control%20it%20with%20the%20%60account.accountLinking%60%20config%20%28e.g.%2C%20trustedProviders%2C%20allowDifferentEmails%29.%20%5BReference%5D%28https%3A//github.com/better-auth/better-auth/discussions/6385%29%20%5BReference%5D%28https%3A//github.com/better-auth/better-auth/issues/7205%29%0A%0AIf%20you%20want%20code%20samples%20or%20more%20details%20on%20either%20flow%2C%20let%20me%20know%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 15, 2026):

@dosu

I have added two callback URIs in Google console Authorized redirect URIs:
http://localhost:5001/v1/bauth/callback/google
https://api1.mydomain.com//v1/bauth/callback/google

I'm initiating the flow from Nextjs on localhost:3000.

When I use http://localhost:5001 auth is successful while it fails with state_mistmatch for https://api1.mydomain.com.

Why so? Below are my auth config.

export const auth = betterAuth({
  secret: betterAuthSecret,
  database: prismaAdapter(prismaClient, {
    provider: 'postgresql',
    transaction: true,
  }),
  basePath: `/${betterAuthBasePath}/bauth`,
  socialProviders: {
    google: {
      prompt: 'select_account',
      clientId: googleClientId,
      clientSecret: googleClientSecret,
      mapProfileToUser: () => {
        return {
          status: UserStatus.email_verified, // set user status as email_verified automatically if its Google signup
        };
      },
    },
  },
  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: [SocialAccounts.google, SocialAccounts.apple],
    },
  },
  cookies: {
    state: {
      attributes: {
        sameSite: 'none',
        secure: true,
      },
    },
  },
  trustedOrigins: corsAllowedOrigins,
  advanced: {
    disableOriginCheck: nodeEnv !== (NodeEnv.PRODUCTION as string),
    useSecureCookies: true,
  },
....
<!-- gh-comment-id:3756624415 --> @iamibadd commented on GitHub (Jan 15, 2026): @dosu I have added two callback URIs in Google console Authorized redirect URIs: http://localhost:5001/v1/bauth/callback/google https://api1.mydomain.com//v1/bauth/callback/google I'm initiating the flow from Nextjs on localhost:3000. When I use http://localhost:5001 auth is successful while it fails with `state_mistmatch` for https://api1.mydomain.com. Why so? Below are my auth config. ```JS export const auth = betterAuth({ secret: betterAuthSecret, database: prismaAdapter(prismaClient, { provider: 'postgresql', transaction: true, }), basePath: `/${betterAuthBasePath}/bauth`, socialProviders: { google: { prompt: 'select_account', clientId: googleClientId, clientSecret: googleClientSecret, mapProfileToUser: () => { return { status: UserStatus.email_verified, // set user status as email_verified automatically if its Google signup }; }, }, }, account: { accountLinking: { enabled: true, trustedProviders: [SocialAccounts.google, SocialAccounts.apple], }, }, cookies: { state: { attributes: { sameSite: 'none', secure: true, }, }, }, trustedOrigins: corsAllowedOrigins, advanced: { disableOriginCheck: nodeEnv !== (NodeEnv.PRODUCTION as string), useSecureCookies: true, }, .... ```
Author
Owner

@dosubot[bot] commented on GitHub (Jan 15, 2026):

The state_mismatch error on https://api1.mydomain.com happens because the OAuth state cookie (used for CSRF protection) is not being set or sent correctly during the callback. This is almost always due to one or more of these cross-domain requirements not being met:

  • Both your frontend (Next.js) and backend (Better Auth/NestJS) must be served over HTTPS with valid certificates. Browsers will not send Secure cookies over HTTP, so the state cookie is never sent back to the callback endpoint on https://api1.mydomain.com, causing state validation to fail reference reference.
  • The OAuth flow must be initiated from the frontend (browser) using authClient.signIn.social, not from the backend. This ensures the state cookie is set in the browser context reference.
  • Your Better Auth config must set the state cookie with SameSite: 'none' and Secure: true (which you have), and all frontend origins (including https://api1.mydomain.com and http://localhost:3000) must be listed in trustedOrigins reference.
  • The callback URI in the Google console must match exactly (protocol, domain, path, and no double slashes) with the callback endpoint in Better Auth and the actual request reference.
  • CORS on your backend must allow the requesting frontend origin and set Access-Control-Allow-Credentials: true.

If auth works on localhost but fails on your remote domain, it's almost always because Secure cookies require HTTPS and/or the domain is not in trustedOrigins. Double-check that https://api1.mydomain.com is using HTTPS, that your browser isn't blocking cookies, and that all origins are correctly listed in trustedOrigins. If you use a proxy or CDN, make sure it doesn't strip Set-Cookie headers.

If you need to support multiple subdomains, consider enabling crossSubDomainCookies with domain: '.mydomain.com' (not a public suffix), but cookies cannot be shared across unrelated domains due to browser security reference.

For a step-by-step checklist and more debugging tips, see the linked issues above.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3756652482 --> @dosubot[bot] commented on GitHub (Jan 15, 2026): <!-- Answer --> The state_mismatch error on https://api1.mydomain.com happens because the OAuth state cookie (used for CSRF protection) is not being set or sent correctly during the callback. This is almost always due to one or more of these cross-domain requirements not being met: - Both your frontend (Next.js) and backend (Better Auth/NestJS) must be served over HTTPS with valid certificates. Browsers will not send Secure cookies over HTTP, so the state cookie is never sent back to the callback endpoint on https://api1.mydomain.com, causing state validation to fail [reference](https://github.com/better-auth/better-auth/issues/6530) [reference](https://github.com/better-auth/better-auth/discussions/5670). - The OAuth flow must be initiated from the frontend (browser) using authClient.signIn.social, not from the backend. This ensures the state cookie is set in the browser context [reference](https://github.com/better-auth/better-auth/issues/5478). - Your Better Auth config must set the state cookie with SameSite: 'none' and Secure: true (which you have), and all frontend origins (including https://api1.mydomain.com and http://localhost:3000) must be listed in trustedOrigins [reference](https://github.com/better-auth/better-auth/issues/7023). - The callback URI in the Google console must match exactly (protocol, domain, path, and no double slashes) with the callback endpoint in Better Auth and the actual request [reference](https://github.com/better-auth/better-auth/issues/6483). - CORS on your backend must allow the requesting frontend origin and set Access-Control-Allow-Credentials: true. If auth works on localhost but fails on your remote domain, it's almost always because Secure cookies require HTTPS and/or the domain is not in trustedOrigins. Double-check that https://api1.mydomain.com is using HTTPS, that your browser isn't blocking cookies, and that all origins are correctly listed in trustedOrigins. If you use a proxy or CDN, make sure it doesn't strip Set-Cookie headers. If you need to support multiple subdomains, consider enabling crossSubDomainCookies with domain: '.mydomain.com' (not a public suffix), but cookies cannot be shared across unrelated domains due to browser security [reference](https://github.com/better-auth/better-auth/issues/7283). For a step-by-step checklist and more debugging tips, see the linked issues above. <!-- 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/02b60fd4-d4bf-401e-b4be-7633325b9b00?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/02b60fd4-d4bf-401e-b4be-7633325b9b00?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/02b60fd4-d4bf-401e-b4be-7633325b9b00?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/02b60fd4-d4bf-401e-b4be-7633325b9b00?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/02b60fd4-d4bf-401e-b4be-7633325b9b00?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/02b60fd4-d4bf-401e-b4be-7633325b9b00?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/02b60fd4-d4bf-401e-b4be-7633325b9b00?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=The%20state_mismatch%20error%20on%20https%3A//api1.mydomain.com%20happens%20because%20the%20OAuth%20state%20cookie%20%28used%20for%20CSRF%20protection%29%20is%20not%20being%20set%20or%20sent%20correctly%20during%20the%20callback.%20This%20is%20almost%20always%20due%20to%20one%20or%20more%20of%20these%20cross-domain%20requirements%20not%20being%20met%3A%0A%0A-%20Both%20your%20frontend%20%28Next.js%29%20and%20backend%20%28Better%20Auth/NestJS%29%20must%20be%20served%20over%20HTTPS%20with%20valid%20certificates.%20Browsers%20will%20not%20send%20Secure%20cookies%20over%20HTTP%2C%20so%20the%20state%20cookie%20is%20never%20sent%20back%20to%20the%20callback%20endpoint%20on%20https%3A//api1.mydomain.com%2C%20causing%20state%20validation%20to%20fail%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/6530%29%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/discussions/5670%29.%0A-%20The%20OAuth%20flow%20must%20be%20initiated%20from%20the%20frontend%20%28browser%29%20using%20authClient.signIn.social%2C%20not%20from%20the%20backend.%20This%20ensures%20the%20state%20cookie%20is%20set%20in%20the%20browser%20context%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/5478%29.%0A-%20Your%20Better%20Auth%20config%20must%20set%20the%20state%20cookie%20with%20SameSite%3A%20%27none%27%20and%20Secure%3A%20true%20%28which%20you%20have%29%2C%20and%20all%20frontend%20origins%20%28including%20https%3A//api1.mydomain.com%20and%20http%3A//localhost%3A3000%29%20must%20be%20listed%20in%20trustedOrigins%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/7023%29.%0A-%20The%20callback%20URI%20in%20the%20Google%20console%20must%20match%20exactly%20%28protocol%2C%20domain%2C%20path%2C%20and%20no%20double%20slashes%29%20with%20the%20callback%20endpoint%20in%20Better%20Auth%20and%20the%20actual%20request%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/6483%29.%0A-%20CORS%20on%20your%20backend%20must%20allow%20the%20requesting%20frontend%20origin%20and%20set%20Access-Control-Allow-Credentials%3A%20true.%0A%0AIf%20auth%20works%20on%20localhost%20but%20fails%20on%20your%20remote%20domain%2C%20it%27s%20almost%20always%20because%20Secure%20cookies%20require%20HTTPS%20and/or%20the%20domain%20is%20not%20in%20trustedOrigins.%20Double-check%20that%20https%3A//api1.mydomain.com%20is%20using%20HTTPS%2C%20that%20your%20browser%20isn%27t%20blocking%20cookies%2C%20and%20that%20all%20origins%20are%20correctly%20listed%20in%20trustedOrigins.%20If%20you%20use%20a%20proxy%20or%20CDN%2C%20make%20sure%20it%20doesn%27t%20strip%20Set-Cookie%20headers.%0A%0AIf%20you%20need%20to%20support%20multiple%20subdomains%2C%20consider%20enabling%20crossSubDomainCookies%20with%20domain%3A%20%27.mydomain.com%27%20%28not%20a%20public%20suffix%29%2C%20but%20cookies%20cannot%20be%20shared%20across%20unrelated%20domains%20due%20to%20browser%20security%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/7283%29.%0A%0AFor%20a%20step-by-step%20checklist%20and%20more%20debugging%20tips%2C%20see%20the%20linked%20issues%20above.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 15, 2026):

@dosu

So you mean that calls from localhost:3000 won't be entertained by api.domain.com since I'm using secure cookies?

<!-- gh-comment-id:3756932209 --> @iamibadd commented on GitHub (Jan 15, 2026): @dosu So you mean that calls from localhost:3000 won't be entertained by api.domain.com since I'm using secure cookies?
Author
Owner

@dosubot[bot] commented on GitHub (Jan 15, 2026):

You've reached the anonymous usage limit of 10 interactions per month. Your usage will reset on 2026-02-01. If you'd like to use Dosu before then, please register to increase your limit to 100 interactions per month for free.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3756933000 --> @dosubot[bot] commented on GitHub (Jan 15, 2026): <!-- Answer --> You've reached the anonymous usage limit of `10` interactions per month. Your usage will reset on `2026-02-01`. If you'd like to use Dosu before then, please [register](https://app.dosu.dev/login?referrer=from-anonymous-rate-limit) to increase your limit to `100` interactions per month for free. <!-- 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/659649e2-f032-4da8-885e-088ac59624b5?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/659649e2-f032-4da8-885e-088ac59624b5?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/659649e2-f032-4da8-885e-088ac59624b5?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/659649e2-f032-4da8-885e-088ac59624b5?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/659649e2-f032-4da8-885e-088ac59624b5?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/659649e2-f032-4da8-885e-088ac59624b5?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/659649e2-f032-4da8-885e-088ac59624b5?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%27ve%20reached%20the%20anonymous%20usage%20limit%20of%20%6010%60%20interactions%20per%20month.%20Your%20usage%20will%20reset%20on%20%602026-02-01%60.%20If%20you%27d%20like%20to%20use%20Dosu%20before%20then%2C%20please%20%5Bregister%5D%28https%3A//app.dosu.dev/login%3Freferrer%3Dfrom-anonymous-rate-limit%29%20to%20increase%20your%20limit%20to%20%60100%60%20interactions%20per%20month%20for%20free.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 15, 2026):

@dosu

So you mean that calls from localhost:3000 won't be entertained by api.domain.com since I'm using secure cookies?

<!-- gh-comment-id:3756940241 --> @iamibadd commented on GitHub (Jan 15, 2026): @dosu So you mean that calls from localhost:3000 won't be entertained by api.domain.com since I'm using secure cookies?
Author
Owner

@dosubot[bot] commented on GitHub (Jan 15, 2026):

Correct—if your frontend is running on localhost:3000 (usually HTTP) and your backend is on api.domain.com (HTTPS), browsers will not send or accept Secure cookies from the backend when making requests from localhost. Secure cookies (Secure: true) are only sent over HTTPS, and cookies set for a remote domain (like .domain.com) are not accessible from localhost due to browser restrictions. This breaks authentication flows that rely on cookies for session or OAuth state, causing issues like state_mismatch errors in OAuth callbacks reference reference reference.

For local development, you can either:

  • Run your frontend on HTTPS (with a self-signed cert) and use a custom domain mapped to localhost, so both frontend and backend share a parent domain.
  • Temporarily set secure: false for cookies in your Better Auth config (not recommended for production).
  • Use a proxy to make both frontend and backend appear under the same domain.

In production, both frontend and backend must be served over HTTPS, and cookies must be set with SameSite: 'none' and Secure: true for cross-origin authentication to work reference.

Let me know if you want a setup example for local development or have a specific scenario in mind!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3756960585 --> @dosubot[bot] commented on GitHub (Jan 15, 2026): <!-- Answer --> Correct—if your frontend is running on `localhost:3000` (usually HTTP) and your backend is on `api.domain.com` (HTTPS), browsers will not send or accept Secure cookies from the backend when making requests from localhost. Secure cookies (`Secure: true`) are only sent over HTTPS, and cookies set for a remote domain (like `.domain.com`) are not accessible from localhost due to browser restrictions. This breaks authentication flows that rely on cookies for session or OAuth state, causing issues like `state_mismatch` errors in OAuth callbacks [reference](https://github.com/better-auth/better-auth/issues/4555) [reference](https://github.com/better-auth/better-auth/issues/4100) [reference](https://github.com/better-auth/better-auth/discussions/5670). For local development, you can either: - Run your frontend on HTTPS (with a self-signed cert) and use a custom domain mapped to localhost, so both frontend and backend share a parent domain. - Temporarily set `secure: false` for cookies in your Better Auth config (not recommended for production). - Use a proxy to make both frontend and backend appear under the same domain. In production, both frontend and backend must be served over HTTPS, and cookies must be set with `SameSite: 'none'` and `Secure: true` for cross-origin authentication to work [reference](https://github.com/better-auth/better-auth/issues/3470). Let me know if you want a setup example for local development or have a specific scenario in mind! <!-- 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/44e2c47c-778e-4189-8097-16bbcd53d44f?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/44e2c47c-778e-4189-8097-16bbcd53d44f?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/44e2c47c-778e-4189-8097-16bbcd53d44f?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/44e2c47c-778e-4189-8097-16bbcd53d44f?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/44e2c47c-778e-4189-8097-16bbcd53d44f?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/44e2c47c-778e-4189-8097-16bbcd53d44f?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/44e2c47c-778e-4189-8097-16bbcd53d44f?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Correct%E2%80%94if%20your%20frontend%20is%20running%20on%20%60localhost%3A3000%60%20%28usually%20HTTP%29%20and%20your%20backend%20is%20on%20%60api.domain.com%60%20%28HTTPS%29%2C%20browsers%20will%20not%20send%20or%20accept%20Secure%20cookies%20from%20the%20backend%20when%20making%20requests%20from%20localhost.%20Secure%20cookies%20%28%60Secure%3A%20true%60%29%20are%20only%20sent%20over%20HTTPS%2C%20and%20cookies%20set%20for%20a%20remote%20domain%20%28like%20%60.domain.com%60%29%20are%20not%20accessible%20from%20localhost%20due%20to%20browser%20restrictions.%20This%20breaks%20authentication%20flows%20that%20rely%20on%20cookies%20for%20session%20or%20OAuth%20state%2C%20causing%20issues%20like%20%60state_mismatch%60%20errors%20in%20OAuth%20callbacks%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/4555%29%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/4100%29%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/discussions/5670%29.%0A%0AFor%20local%20development%2C%20you%20can%20either%3A%0A-%20Run%20your%20frontend%20on%20HTTPS%20%28with%20a%20self-signed%20cert%29%20and%20use%20a%20custom%20domain%20mapped%20to%20localhost%2C%20so%20both%20frontend%20and%20backend%20share%20a%20parent%20domain.%0A-%20Temporarily%20set%20%60secure%3A%20false%60%20for%20cookies%20in%20your%20Better%20Auth%20config%20%28not%20recommended%20for%20production%29.%0A-%20Use%20a%20proxy%20to%20make%20both%20frontend%20and%20backend%20appear%20under%20the%20same%20domain.%0A%0AIn%20production%2C%20both%20frontend%20and%20backend%20must%20be%20served%20over%20HTTPS%2C%20and%20cookies%20must%20be%20set%20with%20%60SameSite%3A%20%27none%27%60%20and%20%60Secure%3A%20true%60%20for%20cross-origin%20authentication%20to%20work%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/3470%29.%0A%0ALet%20me%20know%20if%20you%20want%20a%20setup%20example%20for%20local%20development%20or%20have%20a%20specific%20scenario%20in%20mind%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 15, 2026):

@dosu

I also noticed one thing, in my Nestjs backend after login if I set cookies like

function setAuthCookies(headers: Headers, res: Response) {
  // Collect all Set-Cookie headers (can be multiple)
  headers?.forEach((value, key) => {
    if (key.toLowerCase() === 'set-cookie') {
      res.append('Set-Cookie', value);
    }
  });

// my login route in Nestjs
 const data = await auth.api.signInEmail({
        body: { email, password, rememberMe },
        headers: toFetchHeaders(headers),
        returnHeaders: true,
      });

      setAuthCookies(data.headers, res);

and deploy the backend using a domain, i.e. https://api.mydomain.com and call backend from Nextjs, localhost:3000, then upon login, cookies are not set up in the browser and protected APIs fail with 401.

I see this in the network tab,

"This attempt to set cookie via Set-Cookie header was blocked because it had the SameSite=Lax attribute and came from a cross-site response which was not a top-level navigation."

<!-- gh-comment-id:3757187802 --> @iamibadd commented on GitHub (Jan 15, 2026): @dosu I also noticed one thing, in my Nestjs backend after login if I set cookies like ```JS function setAuthCookies(headers: Headers, res: Response) { // Collect all Set-Cookie headers (can be multiple) headers?.forEach((value, key) => { if (key.toLowerCase() === 'set-cookie') { res.append('Set-Cookie', value); } }); // my login route in Nestjs const data = await auth.api.signInEmail({ body: { email, password, rememberMe }, headers: toFetchHeaders(headers), returnHeaders: true, }); setAuthCookies(data.headers, res); ``` and deploy the backend using a domain, i.e. https://api.mydomain.com and call backend from Nextjs, localhost:3000, then upon login, cookies are not set up in the browser and protected APIs fail with 401. I see this in the network tab, "This attempt to set cookie via Set-Cookie header was blocked because it had the SameSite=Lax attribute and came from a cross-site response which was not a top-level navigation."
Author
Owner

@dosubot[bot] commented on GitHub (Jan 15, 2026):

The browser blocks cookies with SameSite=Lax on cross-origin XHR/fetch responses unless the request is a top-level navigation (like a redirect). That's why you see the warning and why your session cookies aren't set, resulting in 401s on protected APIs reference.

To fix this, update your Better Auth config so session cookies use SameSite: "none" and Secure: true (required for cross-origin cookies). You can set this globally or per cookie:

export const auth = betterAuth({
  // ...your config
  advanced: {
    defaultCookieAttributes: {
      sameSite: "none",
      secure: true,
      httpOnly: true,
    },
    // Or, for just the session cookie:
    cookies: {
      session_token: {
        attributes: {
          sameSite: "none",
          secure: true,
          httpOnly: true,
        },
      },
    },
  },
});

reference reference

For cross-origin cookies to work, both frontend and backend must be served over HTTPS, your frontend requests must use credentials: "include" (fetch) or withCredentials: true (axios), and your backend CORS config must allow credentials and echo the exact origin reference.

For local development, browsers treat localhost as insecure, so Secure cookies may not be sent unless you use HTTPS locally or set secure: false (not recommended for production). You can't share cookies between localhost and a remote domain due to browser restrictions reference.

If you need a local workaround, use a proxy or custom local domains mapped to 127.0.0.1 so both frontend and backend share a parent domain, or temporarily relax cookie security for dev only.

Check your Set-Cookie headers in the browser dev tools—they should show SameSite=None; Secure and the correct domain. If you still see issues, double-check for CORS, proxy/CDN interference, or browser-specific cookie policies.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3757206624 --> @dosubot[bot] commented on GitHub (Jan 15, 2026): <!-- Answer --> The browser blocks cookies with `SameSite=Lax` on cross-origin XHR/fetch responses unless the request is a top-level navigation (like a redirect). That's why you see the warning and why your session cookies aren't set, resulting in 401s on protected APIs [reference](https://github.com/better-auth/better-auth/issues/3470). To fix this, update your Better Auth config so session cookies use `SameSite: "none"` and `Secure: true` (required for cross-origin cookies). You can set this globally or per cookie: ```js export const auth = betterAuth({ // ...your config advanced: { defaultCookieAttributes: { sameSite: "none", secure: true, httpOnly: true, }, // Or, for just the session cookie: cookies: { session_token: { attributes: { sameSite: "none", secure: true, httpOnly: true, }, }, }, }, }); ``` [reference](https://github.com/better-auth/better-auth/issues/3470) [reference](https://github.com/better-auth/better-auth/issues/4483) For cross-origin cookies to work, both frontend and backend must be served over HTTPS, your frontend requests must use `credentials: "include"` (fetch) or `withCredentials: true` (axios), and your backend CORS config must allow credentials and echo the exact origin [reference](https://github.com/better-auth/better-auth/issues/5946). For local development, browsers treat `localhost` as insecure, so Secure cookies may not be sent unless you use HTTPS locally or set `secure: false` (not recommended for production). You can't share cookies between `localhost` and a remote domain due to browser restrictions [reference](https://github.com/better-auth/better-auth/issues/5946). If you need a local workaround, use a proxy or custom local domains mapped to `127.0.0.1` so both frontend and backend share a parent domain, or temporarily relax cookie security for dev only. Check your Set-Cookie headers in the browser dev tools—they should show `SameSite=None; Secure` and the correct domain. If you still see issues, double-check for CORS, proxy/CDN interference, or browser-specific cookie policies. <!-- 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/b1ba5fc6-955c-4b70-8be6-6781afe15bd1?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/b1ba5fc6-955c-4b70-8be6-6781afe15bd1?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/b1ba5fc6-955c-4b70-8be6-6781afe15bd1?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/b1ba5fc6-955c-4b70-8be6-6781afe15bd1?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/b1ba5fc6-955c-4b70-8be6-6781afe15bd1?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/b1ba5fc6-955c-4b70-8be6-6781afe15bd1?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/b1ba5fc6-955c-4b70-8be6-6781afe15bd1?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=The%20browser%20blocks%20cookies%20with%20%60SameSite%3DLax%60%20on%20cross-origin%20XHR/fetch%20responses%20unless%20the%20request%20is%20a%20top-level%20navigation%20%28like%20a%20redirect%29.%20That%27s%20why%20you%20see%20the%20warning%20and%20why%20your%20session%20cookies%20aren%27t%20set%2C%20resulting%20in%20401s%20on%20protected%20APIs%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/3470%29.%0A%0ATo%20fix%20this%2C%20update%20your%20Better%20Auth%20config%20so%20session%20cookies%20use%20%60SameSite%3A%20%22none%22%60%20and%20%60Secure%3A%20true%60%20%28required%20for%20cross-origin%20cookies%29.%20You%20can%20set%20this%20globally%20or%20per%20cookie%3A%0A%0A%60%60%60js%0Aexport%20const%20auth%20%3D%20betterAuth%28%7B%0A%20%20//%20...your%20config%0A%20%20advanced%3A%20%7B%0A%20%20%20%20defaultCookieAttributes%3A%20%7B%0A%20%20%20%20%20%20sameSite%3A%20%22none%22%2C%0A%20%20%20%20%20%20secure%3A%20true%2C%0A%20%20%20%20%20%20httpOnly%3A%20true%2C%0A%20%20%20%20%7D%2C%0A%20%20%20%20//%20Or%2C%20for%20just%20the%20session%20cookie%3A%0A%20%20%20%20cookies%3A%20%7B%0A%20%20%20%20%20%20session_token%3A%20%7B%0A%20%20%20%20%20%20%20%20attributes%3A%20%7B%0A%20%20%20%20%20%20%20%20%20%20sameSite%3A%20%22none%22%2C%0A%20%20%20%20%20%20%20%20%20%20secure%3A%20true%2C%0A%20%20%20%20%20%20%20%20%20%20httpOnly%3A%20true%2C%0A%20%20%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%7D%2C%0A%20%20%7D%2C%0A%7D%29%3B%0A%60%60%60%0A%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/3470%29%20%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/4483%29%0A%0AFor%20cross-origin%20cookies%20to%20work%2C%20both%20frontend%20and%20backend%20must%20be%20served%20over%20HTTPS%2C%20your%20frontend%20requests%20must%20use%20%60credentials%3A%20%22include%22%60%20%28fetch%29%20or%20%60withCredentials%3A%20true%60%20%28axios%29%2C%20and%20your%20backend%20CORS%20config%20must%20allow%20credentials%20and%20echo%20the%20exact%20origin%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/5946%29.%0A%0AFor%20local%20development%2C%20browsers%20treat%20%60localhost%60%20as%20insecure%2C%20so%20Secure%20cookies%20may%20not%20be%20sent%20unless%20you%20use%20HTTPS%20locally%20or%20set%20%60secure%3A%20false%60%20%28not%20recommended%20for%20production%29.%20You%20can%27t%20share%20cookies%20between%20%60localhost%60%20and%20a%20remote%20domain%20due%20to%20browser%20restrictions%C2%A0%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/5946%29.%0A%0AIf%20you%20need%20a%20local%20workaround%2C%20use%20a%20proxy%20or%20custom%20local%20domains%20mapped%20to%20%60127.0.0.1%60%20so%20both%20frontend%20and%20backend%20share%20a%20parent%20domain%2C%20or%20temporarily%20relax%20cookie%20security%20for%20dev%20only.%0A%0ACheck%20your%20Set-Cookie%20headers%20in%20the%20browser%20dev%20tools%E2%80%94they%20should%20show%20%60SameSite%3DNone%3B%20Secure%60%20and%20the%20correct%20domain.%20If%20you%20still%20see%20issues%2C%20double-check%20for%20CORS%2C%20proxy/CDN%20interference%2C%20or%20browser-specific%20cookie%20policies.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 25, 2026):

@dosu

What is the correct usage of useSession on the client side in Nextjs?

  const SignInForm = ({
  onSubmit,
  emailRegister,
  passwordRegister,
  serverError,
  setServerError,
  serverErrorLocked,
  setServerErrorLocked,
  errors,
  isSubmitting,
  isValid,
}: SignInFormProps) => {
  const t = useTranslations();
  const [isLoading, setIsLoading] = useState(false);

  const backendBaseUrl = 'http://localhost:5001';
  const backendApiVersion = 'v1';
  const authClient = createAuthClient({
    baseURL: backendBaseUrl,
    basePath: `/${backendApiVersion}/bauth`,
  });

  const { useSession } = authClient;

  const {
    data: session,
    isPending, //loading state
    error, //error object
    refetch, //refetch the session
  } = useSession();
...

Typescript complains with the below for useSession.

This expression is not callable.
  No constituent of type 'Atom<{ data: { user: { id: string; createdAt: Date; updatedAt: Date; email: string; emailVerified: boolean; name: string; image?: string | null | undefined; }; session: { id: string; createdAt: Date; ... 5 more ...; userAgent?: string | ... 1 more ... | undefined; }; } | null; error: BetterFetchError | null; isPendi...' is callable.
<!-- gh-comment-id:3797100130 --> @iamibadd commented on GitHub (Jan 25, 2026): @dosu What is the correct usage of `useSession` on the client side in Nextjs? ```JS const SignInForm = ({ onSubmit, emailRegister, passwordRegister, serverError, setServerError, serverErrorLocked, setServerErrorLocked, errors, isSubmitting, isValid, }: SignInFormProps) => { const t = useTranslations(); const [isLoading, setIsLoading] = useState(false); const backendBaseUrl = 'http://localhost:5001'; const backendApiVersion = 'v1'; const authClient = createAuthClient({ baseURL: backendBaseUrl, basePath: `/${backendApiVersion}/bauth`, }); const { useSession } = authClient; const { data: session, isPending, //loading state error, //error object refetch, //refetch the session } = useSession(); ... ``` Typescript complains with the below for `useSession`. ``` This expression is not callable. No constituent of type 'Atom<{ data: { user: { id: string; createdAt: Date; updatedAt: Date; email: string; emailVerified: boolean; name: string; image?: string | null | undefined; }; session: { id: string; createdAt: Date; ... 5 more ...; userAgent?: string | ... 1 more ... | undefined; }; } | null; error: BetterFetchError | null; isPendi...' is callable. ```
Author
Owner

@dosubot[bot] commented on GitHub (Jan 25, 2026):

The TypeScript error happens because you're importing createAuthClient from "better-auth/client", which gives you an atom-like object for useSession (not a callable hook). For React/Next.js, you need to import from "better-auth/react"—this provides a real React hook for useSession() that works as expected in client components (source, source, source).

Update your import like this:

import { createAuthClient } from "better-auth/react";

Then you can use either of these patterns (just don’t mix them in the same component):

// Pattern 1: Destructure from the client
const { useSession } = authClient;
const { data: session, isPending, error, refetch } = useSession();

// Pattern 2: Call directly from the client
const { data: session, isPending, error, refetch } = authClient.useSession();

Mixing destructured and direct calls (e.g., authClient.signIn(...); useSession();) can break reactivity and cause subtle bugs (source).

No extra provider or context is needed—useSession is a standard React hook. If you run into hydration issues in Next.js, consider wrapping your component in a hydration guard or using a custom SessionProvider (source).

Let me know if you still see errors after switching to the "better-auth/react" import!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3797107001 --> @dosubot[bot] commented on GitHub (Jan 25, 2026): <!-- Answer --> The TypeScript error happens because you're importing `createAuthClient` from `"better-auth/client"`, which gives you an atom-like object for `useSession` (not a callable hook). For React/Next.js, you need to import from `"better-auth/react"`—this provides a real React hook for `useSession()` that works as expected in client components ([source](https://github.com/better-auth/better-auth/issues/4105), [source](https://github.com/better-auth/better-auth/issues/903), [source](https://github.com/better-auth/better-auth/issues/1006)). Update your import like this: ```js import { createAuthClient } from "better-auth/react"; ``` Then you can use either of these patterns (just don’t mix them in the same component): ```js // Pattern 1: Destructure from the client const { useSession } = authClient; const { data: session, isPending, error, refetch } = useSession(); // Pattern 2: Call directly from the client const { data: session, isPending, error, refetch } = authClient.useSession(); ``` Mixing destructured and direct calls (e.g., `authClient.signIn(...); useSession();`) can break reactivity and cause subtle bugs ([source](https://github.com/better-auth/better-auth/issues/1006)). No extra provider or context is needed—`useSession` is a standard React hook. If you run into hydration issues in Next.js, consider wrapping your component in a hydration guard or using a custom `SessionProvider` ([source](https://github.com/better-auth/better-auth/issues/4609)). Let me know if you still see errors after switching to the `"better-auth/react"` import! <!-- 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/65e013c2-24f5-4911-95d3-28a14afd9f19?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/65e013c2-24f5-4911-95d3-28a14afd9f19?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/65e013c2-24f5-4911-95d3-28a14afd9f19?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/65e013c2-24f5-4911-95d3-28a14afd9f19?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/65e013c2-24f5-4911-95d3-28a14afd9f19?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/65e013c2-24f5-4911-95d3-28a14afd9f19?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/65e013c2-24f5-4911-95d3-28a14afd9f19?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=The%20TypeScript%20error%20happens%20because%20you%27re%20importing%20%60createAuthClient%60%20from%20%60%22better-auth/client%22%60%2C%20which%20gives%20you%20an%20atom-like%20object%20for%20%60useSession%60%20%28not%20a%20callable%20hook%29.%20For%20React/Next.js%2C%20you%20need%20to%20import%20from%20%60%22better-auth/react%22%60%E2%80%94this%20provides%20a%20real%20React%20hook%20for%20%60useSession%28%29%60%20that%20works%20as%20expected%20in%20client%20components%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/4105%29%2C%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/903%29%2C%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/1006%29%29.%0A%0AUpdate%20your%20import%20like%20this%3A%0A%0A%60%60%60js%0Aimport%20%7B%20createAuthClient%20%7D%20from%20%22better-auth/react%22%3B%0A%60%60%60%0A%0AThen%20you%20can%20use%20either%20of%20these%20patterns%20%28just%20don%E2%80%99t%20mix%20them%20in%20the%20same%20component%29%3A%0A%0A%60%60%60js%0A//%20Pattern%201%3A%20Destructure%20from%20the%20client%0Aconst%20%7B%20useSession%20%7D%20%3D%20authClient%3B%0Aconst%20%7B%20data%3A%20session%2C%20isPending%2C%20error%2C%20refetch%20%7D%20%3D%20useSession%28%29%3B%0A%0A//%20Pattern%202%3A%20Call%20directly%20from%20the%20client%0Aconst%20%7B%20data%3A%20session%2C%20isPending%2C%20error%2C%20refetch%20%7D%20%3D%20authClient.useSession%28%29%3B%0A%60%60%60%0A%0AMixing%20destructured%20and%20direct%20calls%20%28e.g.%2C%20%60authClient.signIn%28...%29%3B%20useSession%28%29%3B%60%29%20can%20break%20reactivity%20and%20cause%20subtle%20bugs%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/1006%29%29.%0A%0ANo%20extra%20provider%20or%20context%20is%20needed%E2%80%94%60useSession%60%20is%20a%20standard%20React%20hook.%20If%20you%20run%20into%20hydration%20issues%20in%20Next.js%2C%20consider%20wrapping%20your%20component%20in%20a%20hydration%20guard%20or%20using%20a%20custom%20%60SessionProvider%60%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/4609%29%29.%0A%0ALet%20me%20know%20if%20you%20still%20see%20errors%20after%20switching%20to%20the%20%60%22better-auth/react%22%60%20import%21)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 27, 2026):

@dosu,

I’m using better-auth v1.4.17 with social sign-in enabled.

Google OAuth works as expected, but Apple sign-in consistently fails with state_not_found.

From the logs and code snippets I’ve attached, it looks like there’s a behavioral difference between providers:

Google
/callback/google is invoked via GET, and the state parameter is included in the query string.

Apple
/callback/apple is invoked via POST, and the state parameter is included in the request body instead of the query string.

Because of this, Better Auth doesn’t seem to find the state, resulting in state_not_found.

My questions:

Is this a recent change or expected behavior for Apple sign-in in v1.4.17?

Does Better Auth currently expect the state to be present in the query params even for Apple, or should it also handle the body?

For context, in Apple Developer settings:

Frontend domain: merge-shield-basin-members.trycloudflare.com

Backend callback URL:
https://nation-crucial-possession-fast.trycloudflare.com/v1/bauth/callback/apple

Both are added under Website URLs for the Apple Service ID.

backend

  basePath: `/${betterAuthBasePath}/bauth`,
  socialProviders: {
    google: {
      prompt: 'select_account',
      clientId: googleClientId,
      clientSecret: googleClientSecret,
    },
    apple: {
      clientId: appleClientId,
      clientSecret: appleClientSecret,
    },

 trustedOrigins: ["https://merge-shield-basin-members.trycloudflare.com", "https://appleid.apple.com",  "https://nation-crucial-possession-fast.trycloudflare.com"]

fronted

import { createAuthClient } from 'better-auth/react';
const backendBaseUrl = 'https://nation-crucial-possession-fast.trycloudflare.com';
const backendApiVersion = 'v1';
const authClient = createAuthClient({
  baseURL: backendBaseUrl,
  basePath: `/${backendApiVersion}/bauth`,
});

   await authClient.signIn.social({
      provider: 'google',
    });

   await authClient.signIn.social({
      provider: 'apple',
    });

Backend logs

[01:07:30.997] INFO (35079):
    method: "POST"
    url: "/v1/bauth/sign-in/social"
    statusCode: 200
    duration: "112ms"
    body: {
      "provider": "apple"
    }
2026-01-27T20:07:45.117Z ERROR [Better Auth]: State not found undefined
[01:07:45.035] INFO (35079):
    method: "POST"
    url: "/v1/bauth/callback/apple"
    statusCode: 302
    duration: "15ms"
    body: {
      "state": "y9gAalJL9dDqZHhP7fszmEoubhMJoEWJ",
      "code": "cb42678ed0ff543f5af5a7bd243a2c56d.0.stuy.SVhcq1fK7zMK0_I1slU2kQ",
      "id_token": "eyJraWQiOiJiRnd6bGVSOHRmIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmJyYWdtYW50LmFwaTEuc2kiLCJleHAiOjE3Njk2MzA4NjQsImlhdCI6MTc2OTU0NDQ2NCwic3ViIjoiMDAwMzQ4LjkzYzQxYzJjZjY3MzQ0YmM5Y2EwMTdjMWUzOGE1YTczLjE4NDAiLCJjX2hhc2giOiJya2VUZ0NnSURVbW9NS3d4M0J1eDVnIiwiZW1haWwiOiJpYW1pYmFkZEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXV0aF90aW1lIjoxNzY5NTQ0NDY0LCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.OTVJCINhSJZqpaLfBXPkk4esAsFvokSgf_HimC0_f7Mv2DWU9etuqb3Nm87ntGq8heDnQbK4nV-j8RIzO51lKRDKMFs1Gv4t_2Xl-dIlxMlBxKMmgn-s2ppeRJTElT74qbRKVTpkXnaICHfSedraFk9Ihs0RxrCnltV23_LbMRzRNRrgORbQ6RQkiwO1CAoVYiwvrTJqyaPaFeefb20CyxykXwRE98Z4odb6WEM50UhBJdi9ybJmwoadAZz8Cd-JASnRb5z0LbvyuvvjuFlpTcYgWUumG1L1VE-BWn3DdALFwb86ezRfgU4HMruleq3AQx2Ea6ALWGD4Z_lj5c-ZzA"
    }
[01:07:45.118] INFO (35079):
    method: "GET"
    url: "/v1/bauth/callback/apple?"
    statusCode: 302
    duration: "3ms"
[01:07:45.436] INFO (35079):
    method: "GET"
    url: "/v1/bauth/error?state=state_not_found"
    statusCode: 200
    duration: "9ms"
<!-- gh-comment-id:3807358927 --> @iamibadd commented on GitHub (Jan 27, 2026): @dosu, I’m using better-auth v1.4.17 with social sign-in enabled. Google OAuth works as expected, but Apple sign-in consistently fails with `state_not_found`. From the logs and code snippets I’ve attached, it looks like there’s a behavioral difference between providers: Google `/callback/google` is invoked via GET, and the state parameter is included in the query string. Apple `/callback/apple` is invoked via POST, and the state parameter is included in the request body instead of the query string. Because of this, Better Auth doesn’t seem to find the state, resulting in `state_not_found`. My questions: Is this a recent change or expected behavior for Apple sign-in in v1.4.17? Does Better Auth currently expect the state to be present in the query params even for Apple, or should it also handle the body? For context, in Apple Developer settings: Frontend domain: merge-shield-basin-members.trycloudflare.com Backend callback URL: https://nation-crucial-possession-fast.trycloudflare.com/v1/bauth/callback/apple Both are added under Website URLs for the Apple Service ID. backend ```JS basePath: `/${betterAuthBasePath}/bauth`, socialProviders: { google: { prompt: 'select_account', clientId: googleClientId, clientSecret: googleClientSecret, }, apple: { clientId: appleClientId, clientSecret: appleClientSecret, }, trustedOrigins: ["https://merge-shield-basin-members.trycloudflare.com", "https://appleid.apple.com", "https://nation-crucial-possession-fast.trycloudflare.com"] ``` fronted ```JS import { createAuthClient } from 'better-auth/react'; const backendBaseUrl = 'https://nation-crucial-possession-fast.trycloudflare.com'; const backendApiVersion = 'v1'; const authClient = createAuthClient({ baseURL: backendBaseUrl, basePath: `/${backendApiVersion}/bauth`, }); await authClient.signIn.social({ provider: 'google', }); await authClient.signIn.social({ provider: 'apple', }); ``` Backend logs ``` [01:07:30.997] INFO (35079): method: "POST" url: "/v1/bauth/sign-in/social" statusCode: 200 duration: "112ms" body: { "provider": "apple" } 2026-01-27T20:07:45.117Z ERROR [Better Auth]: State not found undefined [01:07:45.035] INFO (35079): method: "POST" url: "/v1/bauth/callback/apple" statusCode: 302 duration: "15ms" body: { "state": "y9gAalJL9dDqZHhP7fszmEoubhMJoEWJ", "code": "cb42678ed0ff543f5af5a7bd243a2c56d.0.stuy.SVhcq1fK7zMK0_I1slU2kQ", "id_token": "eyJraWQiOiJiRnd6bGVSOHRmIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmJyYWdtYW50LmFwaTEuc2kiLCJleHAiOjE3Njk2MzA4NjQsImlhdCI6MTc2OTU0NDQ2NCwic3ViIjoiMDAwMzQ4LjkzYzQxYzJjZjY3MzQ0YmM5Y2EwMTdjMWUzOGE1YTczLjE4NDAiLCJjX2hhc2giOiJya2VUZ0NnSURVbW9NS3d4M0J1eDVnIiwiZW1haWwiOiJpYW1pYmFkZEBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXV0aF90aW1lIjoxNzY5NTQ0NDY0LCJub25jZV9zdXBwb3J0ZWQiOnRydWV9.OTVJCINhSJZqpaLfBXPkk4esAsFvokSgf_HimC0_f7Mv2DWU9etuqb3Nm87ntGq8heDnQbK4nV-j8RIzO51lKRDKMFs1Gv4t_2Xl-dIlxMlBxKMmgn-s2ppeRJTElT74qbRKVTpkXnaICHfSedraFk9Ihs0RxrCnltV23_LbMRzRNRrgORbQ6RQkiwO1CAoVYiwvrTJqyaPaFeefb20CyxykXwRE98Z4odb6WEM50UhBJdi9ybJmwoadAZz8Cd-JASnRb5z0LbvyuvvjuFlpTcYgWUumG1L1VE-BWn3DdALFwb86ezRfgU4HMruleq3AQx2Ea6ALWGD4Z_lj5c-ZzA" } [01:07:45.118] INFO (35079): method: "GET" url: "/v1/bauth/callback/apple?" statusCode: 302 duration: "3ms" [01:07:45.436] INFO (35079): method: "GET" url: "/v1/bauth/error?state=state_not_found" statusCode: 200 duration: "9ms" ```
Author
Owner

@dosubot[bot] commented on GitHub (Jan 27, 2026):

This is a known limitation in better-auth v1.4.17: Apple sends its OAuth callback as a POST with the state parameter in the body, but v1.4.17 does not reliably extract state from the POST body, so you get a state_not_found error. This isn’t a new Apple behavior—it's a gap in better-auth that was fixed in a later release.

A fix was merged in PR #7181 (2026-01-14) that updates the callback handler to extract the state from both the query string and POST body, so Apple sign-in works as expected (see PR #7181). To resolve this, upgrade to the latest better-auth version that includes this PR or newer.

Also, make sure your state cookie config uses SameSite: 'none' and Secure: true, and that all domains are served over HTTPS and listed in trustedOrigins (reference).

Once you upgrade, Apple sign-in should work without the state_not_found error, even when the callback is a POST.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3807375615 --> @dosubot[bot] commented on GitHub (Jan 27, 2026): <!-- Answer --> This is a known limitation in better-auth v1.4.17: Apple sends its OAuth callback as a POST with the state parameter in the body, but v1.4.17 does not reliably extract state from the POST body, so you get a `state_not_found` error. This isn’t a new Apple behavior—it's a gap in better-auth that was fixed in a later release. A fix was merged in PR #7181 (2026-01-14) that updates the callback handler to extract the state from both the query string and POST body, so Apple sign-in works as expected ([see PR #7181](https://github.com/better-auth/better-auth/pull/7181)). To resolve this, upgrade to the latest better-auth version that includes this PR or newer. Also, make sure your state cookie config uses `SameSite: 'none'` and `Secure: true`, and that all domains are served over HTTPS and listed in `trustedOrigins` ([reference](https://github.com/better-auth/better-auth/issues/5243)). Once you upgrade, Apple sign-in should work without the `state_not_found` error, even when the callback is a POST. <!-- 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/125f15bc-afeb-471e-9be3-c6276f938172?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/125f15bc-afeb-471e-9be3-c6276f938172?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/125f15bc-afeb-471e-9be3-c6276f938172?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/125f15bc-afeb-471e-9be3-c6276f938172?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/125f15bc-afeb-471e-9be3-c6276f938172?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/125f15bc-afeb-471e-9be3-c6276f938172?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/125f15bc-afeb-471e-9be3-c6276f938172?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20known%20limitation%20in%20better-auth%20v1.4.17%3A%20Apple%20sends%20its%20OAuth%20callback%20as%20a%20POST%20with%20the%20state%20parameter%20in%20the%20body%2C%20but%20v1.4.17%20does%20not%20reliably%20extract%20state%20from%20the%20POST%20body%2C%20so%20you%20get%20a%20%60state_not_found%60%20error.%20This%20isn%E2%80%99t%20a%20new%20Apple%20behavior%E2%80%94it%27s%20a%20gap%20in%20better-auth%20that%20was%20fixed%20in%20a%20later%20release.%0A%0AA%20fix%20was%20merged%20in%20PR%20%237181%20%282026-01-14%29%20that%20updates%20the%20callback%20handler%20to%20extract%20the%20state%20from%20both%20the%20query%20string%20and%20POST%20body%2C%20so%20Apple%20sign-in%20works%20as%20expected%20%28%5Bsee%20PR%20%237181%5D%28https%3A//github.com/better-auth/better-auth/pull/7181%29%29.%20To%20resolve%20this%2C%20upgrade%20to%20the%20latest%20better-auth%20version%20that%20includes%20this%20PR%20or%20newer.%0A%0AAlso%2C%20make%20sure%20your%20state%20cookie%20config%20uses%20%60SameSite%3A%20%27none%27%60%20and%20%60Secure%3A%20true%60%2C%20and%20that%20all%20domains%20are%20served%20over%20HTTPS%20and%20listed%20in%20%60trustedOrigins%60%20%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/5243%29%29.%0A%0AOnce%20you%20upgrade%2C%20Apple%20sign-in%20should%20work%20without%20the%20%60state_not_found%60%20error%2C%20even%20when%20the%20callback%20is%20a%20POST.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 27, 2026):

@dosu

I did that and I'm using the correct better-auth version. So, my observation is that, even though if I set a random clientSecret in Apple's clientSecret config, I still see state_not_found. I'm guessing, my clientSecret is incorrect. I created the clientSecret from https://www.better-auth.com/docs/authentication/apple by following the docs. How do I verify if the JWT secret is correct?

<!-- gh-comment-id:3807464315 --> @iamibadd commented on GitHub (Jan 27, 2026): @dosu I did that and I'm using the correct better-auth version. So, my observation is that, even though if I set a random clientSecret in Apple's clientSecret config, I still see state_not_found. I'm guessing, my clientSecret is incorrect. I created the clientSecret from https://www.better-auth.com/docs/authentication/apple by following the docs. How do I verify if the JWT secret is correct?
Author
Owner

@dosubot[bot] commented on GitHub (Jan 27, 2026):

To verify your Apple clientSecret JWT, decode it using a tool like jwt.io and check these claims:

  • iss: must be your Apple Team ID
  • sub: must be your Apple Service ID (clientId)
  • aud: must be exactly https://appleid.apple.com
  • iat/exp: issued-at and expiration (exp must be in the future, max 6 months from iat)
  • kid: must match your Key ID from the Apple Developer portal

The JWT must be signed with your downloaded .p8 private key using ES256. If any of these values are wrong, or the signature is invalid, Apple will reject the token and Better Auth will fail the OAuth flow—sometimes surfacing as state_not_found or invalid_code errors, depending on where the failure occurs (reference, reference).

If you set a random string as clientSecret, Apple will always reject it, and the flow will fail. To debug, always check your Better Auth logs for errors during the callback—if the error is invalid_code, it's almost certainly a clientSecret/JWT problem; if it's state_not_found, it could be a cookie/state/callback issue, but a totally invalid clientSecret can sometimes cause state errors too (reference).

For production, generate the clientSecret dynamically and rotate it before it expires (reference). If you want to fully verify the JWT, you can use the jose library to check the signature against Apple’s public keys (reference).

If you want to share your JWT (with sensitive parts redacted), I can help you inspect the claims.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3807490827 --> @dosubot[bot] commented on GitHub (Jan 27, 2026): <!-- Answer --> To verify your Apple clientSecret JWT, decode it using a tool like [jwt.io](https://jwt.io/) and check these claims: - iss: must be your Apple Team ID - sub: must be your Apple Service ID (clientId) - aud: must be exactly https://appleid.apple.com - iat/exp: issued-at and expiration (exp must be in the future, max 6 months from iat) - kid: must match your Key ID from the Apple Developer portal The JWT must be signed with your downloaded .p8 private key using ES256. If any of these values are wrong, or the signature is invalid, Apple will reject the token and Better Auth will fail the OAuth flow—sometimes surfacing as state_not_found or invalid_code errors, depending on where the failure occurs ([reference](https://github.com/better-auth/better-auth/issues/1522), [reference](https://github.com/better-auth/better-auth/issues/7550)). If you set a random string as clientSecret, Apple will always reject it, and the flow will fail. To debug, always check your Better Auth logs for errors during the callback—if the error is invalid_code, it's almost certainly a clientSecret/JWT problem; if it's state_not_found, it could be a cookie/state/callback issue, but a totally invalid clientSecret can sometimes cause state errors too ([reference](https://github.com/better-auth/better-auth/blob/d1602ef0ae9822e462a5a42497072a0a1756e804/packages/better-auth/src/api/routes/callback.ts#L35-L140)). For production, generate the clientSecret dynamically and rotate it before it expires ([reference](https://github.com/better-auth/better-auth/issues/2900)). If you want to fully verify the JWT, you can use the jose library to check the signature against Apple’s public keys ([reference](https://github.com/better-auth/better-auth/blob/d1602ef0ae9822e462a5a42497072a0a1756e804/packages/core/src/social-providers/apple.ts#L3-L222)). If you want to share your JWT (with sensitive parts redacted), I can help you inspect the claims. <!-- 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/91e6e075-d8de-481e-8e57-fb51185bb8f8?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/91e6e075-d8de-481e-8e57-fb51185bb8f8?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/91e6e075-d8de-481e-8e57-fb51185bb8f8?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/91e6e075-d8de-481e-8e57-fb51185bb8f8?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/91e6e075-d8de-481e-8e57-fb51185bb8f8?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/91e6e075-d8de-481e-8e57-fb51185bb8f8?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/91e6e075-d8de-481e-8e57-fb51185bb8f8?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=To%20verify%20your%20Apple%20clientSecret%20JWT%2C%20decode%20it%20using%20a%20tool%20like%20%5Bjwt.io%5D%28https%3A//jwt.io/%29%20and%20check%20these%20claims%3A%0A%0A-%20iss%3A%20must%20be%20your%20Apple%20Team%20ID%0A-%20sub%3A%20must%20be%20your%20Apple%20Service%20ID%20%28clientId%29%0A-%20aud%3A%20must%20be%20exactly%20https%3A//appleid.apple.com%0A-%20iat/exp%3A%20issued-at%20and%20expiration%20%28exp%20must%20be%20in%20the%20future%2C%20max%206%20months%20from%20iat%29%0A-%20kid%3A%20must%20match%20your%20Key%20ID%20from%20the%20Apple%20Developer%20portal%0A%0AThe%20JWT%20must%20be%20signed%20with%20your%20downloaded%20.p8%20private%20key%20using%20ES256.%20If%20any%20of%20these%20values%20are%20wrong%2C%20or%20the%20signature%20is%20invalid%2C%20Apple%20will%20reject%20the%20token%20and%20Better%20Auth%20will%20fail%20the%20OAuth%20flow%E2%80%94sometimes%20surfacing%20as%20state_not_found%20or%20invalid_code%20errors%2C%20depending%20on%20where%20the%20failure%20occurs%20%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/1522%29%2C%20%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/7550%29%29.%0A%0AIf%20you%20set%20a%20random%20string%20as%20clientSecret%2C%20Apple%20will%20always%20reject%20it%2C%20and%20the%20flow%20will%20fail.%20To%20debug%2C%20always%20check%20your%20Better%20Auth%20logs%20for%20errors%20during%20the%20callback%E2%80%94if%20the%20error%20is%20invalid_code%2C%20it%27s%20almost%20certainly%20a%20clientSecret/JWT%20problem%3B%20if%20it%27s%20state_not_found%2C%20it%20could%20be%20a%20cookie/state/callback%20issue%2C%20but%20a%20totally%20invalid%20clientSecret%20can%20sometimes%20cause%20state%20errors%20too%20%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/blob/d1602ef0ae9822e462a5a42497072a0a1756e804/packages/better-auth/src/api/routes/callback.ts%23L35-L140%29%29.%0A%0AFor%20production%2C%20generate%20the%20clientSecret%20dynamically%20and%20rotate%20it%20before%20it%20expires%20%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/issues/2900%29%29.%20If%20you%20want%20to%20fully%20verify%20the%20JWT%2C%20you%20can%20use%20the%20jose%20library%20to%20check%20the%20signature%20against%20Apple%E2%80%99s%20public%20keys%20%28%5Breference%5D%28https%3A//github.com/better-auth/better-auth/blob/d1602ef0ae9822e462a5a42497072a0a1756e804/packages/core/src/social-providers/apple.ts%23L3-L222%29%29.%0A%0AIf%20you%20want%20to%20share%20your%20JWT%20%28with%20sensitive%20parts%20redacted%29%2C%20I%20can%20help%20you%20inspect%20the%20claims.)&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/7034)
Author
Owner

@iamibadd commented on GitHub (Jan 27, 2026):

@dosu

JWT decode looks fine

{
  "alg": "ES256",
  "kid": "Key ID", // my key id
  "typ": "JWT"
}

{
  "iss": "Team ID", // my team id
  "aud": "Service ID", // my service uri in reverse
  "sub": "com.bragmant.api1.si",
  "iat": 1769547905,
  "exp": 1785099905
}

I'm sharing my config

backendurl = "https://nation-crucial-possession-fast.trycloudflare.com"

  basePath: '/v1/bauth',
  socialProviders: {
    google: {
      prompt: 'select_account',
      clientId: googleClientId,
      clientSecret: googleClientSecret,
    },
    apple: {
      clientId: appleClientId,
      clientSecret: appleClientSecret,
    },
  },
  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: [SocialAccounts.google, SocialAccounts.apple],
    },
  },
  cookies: {
    state: {
      attributes: {
        sameSite: 'none',
        secure: true,
        httpOnly: true,
      },
    },
  },
  trustedOrigins: {"https://appleid.apple.com,https://merge-shield-basin-members.trycloudflare.com,https://nation-crucial-possession-fast.trycloudflare.com"], 
  advanced: {
    disableOriginCheck: true,
    useSecureCookies: true,
    defaultCookieAttributes: {
      sameSite: 'none',
      secure: true,
      httpOnly: true,
    },
  },
<!-- gh-comment-id:3807563073 --> @iamibadd commented on GitHub (Jan 27, 2026): @dosu JWT decode looks fine ```json { "alg": "ES256", "kid": "Key ID", // my key id "typ": "JWT" } { "iss": "Team ID", // my team id "aud": "Service ID", // my service uri in reverse "sub": "com.bragmant.api1.si", "iat": 1769547905, "exp": 1785099905 } ``` I'm sharing my config backendurl = "https://nation-crucial-possession-fast.trycloudflare.com" ```JS basePath: '/v1/bauth', socialProviders: { google: { prompt: 'select_account', clientId: googleClientId, clientSecret: googleClientSecret, }, apple: { clientId: appleClientId, clientSecret: appleClientSecret, }, }, account: { accountLinking: { enabled: true, trustedProviders: [SocialAccounts.google, SocialAccounts.apple], }, }, cookies: { state: { attributes: { sameSite: 'none', secure: true, httpOnly: true, }, }, }, trustedOrigins: {"https://appleid.apple.com,https://merge-shield-basin-members.trycloudflare.com,https://nation-crucial-possession-fast.trycloudflare.com"], advanced: { disableOriginCheck: true, useSecureCookies: true, defaultCookieAttributes: { sameSite: 'none', secure: true, httpOnly: true, }, }, ```
Author
Owner

@dosubot[bot] commented on GitHub (Jan 27, 2026):

Your JWT's aud claim is set to your Service ID, but for Apple Sign In, it must be exactly https://appleid.apple.com—not your Service ID or its reverse URI. The sub claim should be your Service ID (e.g., com.bragmant.api1.si), and iss must be your Team ID. The JWT must be signed with your .p8 key and the correct kid.

If aud is not exactly https://appleid.apple.com, Apple will reject the clientSecret, and Better Auth will fail the OAuth flow—sometimes surfacing as state_not_found or invalid_code errors, even if your logs aren't explicit about the JWT problem (source).

Regenerate your clientSecret JWT with aud: "https://appleid.apple.com" and keep all other claims as you have them. Then update your Better Auth config and try again. If you still see state_not_found, double-check that your callback URLs and trustedOrigins are correct, and that your cookies are set with SameSite: "none" and Secure: true (source).

If you want to fully verify the JWT, you can use jwt.io to decode and inspect the claims, but signature verification requires the private key and Apple's public keys (source).

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3807600094 --> @dosubot[bot] commented on GitHub (Jan 27, 2026): <!-- Answer --> Your JWT's aud claim is set to your Service ID, but for Apple Sign In, it must be exactly https://appleid.apple.com—not your Service ID or its reverse URI. The sub claim should be your Service ID (e.g., com.bragmant.api1.si), and iss must be your Team ID. The JWT must be signed with your .p8 key and the correct kid. If aud is not exactly https://appleid.apple.com, Apple will reject the clientSecret, and Better Auth will fail the OAuth flow—sometimes surfacing as state_not_found or invalid_code errors, even if your logs aren't explicit about the JWT problem ([source](https://github.com/better-auth/better-auth/issues/1522)). Regenerate your clientSecret JWT with aud: "https://appleid.apple.com" and keep all other claims as you have them. Then update your Better Auth config and try again. If you still see state_not_found, double-check that your callback URLs and trustedOrigins are correct, and that your cookies are set with SameSite: "none" and Secure: true ([source](https://github.com/better-auth/better-auth/issues/5227)). If you want to fully verify the JWT, you can use jwt.io to decode and inspect the claims, but signature verification requires the private key and Apple's public keys ([source](https://github.com/better-auth/better-auth/issues/7550)). <!-- 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/36af7744-3729-403a-9cee-b33aee87aa34?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/36af7744-3729-403a-9cee-b33aee87aa34?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/36af7744-3729-403a-9cee-b33aee87aa34?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/36af7744-3729-403a-9cee-b33aee87aa34?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/36af7744-3729-403a-9cee-b33aee87aa34?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/36af7744-3729-403a-9cee-b33aee87aa34?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/36af7744-3729-403a-9cee-b33aee87aa34?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Your%20JWT%27s%20aud%20claim%20is%20set%20to%20your%20Service%20ID%2C%20but%20for%20Apple%20Sign%20In%2C%20it%20must%20be%20exactly%20https%3A//appleid.apple.com%E2%80%94not%20your%20Service%20ID%20or%20its%20reverse%20URI.%20The%20sub%20claim%20should%20be%20your%20Service%20ID%20%28e.g.%2C%20com.bragmant.api1.si%29%2C%20and%20iss%20must%20be%20your%20Team%20ID.%20The%20JWT%20must%20be%20signed%20with%20your%20.p8%20key%20and%20the%20correct%20kid.%0A%0AIf%20aud%20is%20not%20exactly%20https%3A//appleid.apple.com%2C%20Apple%20will%20reject%20the%20clientSecret%2C%20and%20Better%20Auth%20will%20fail%20the%20OAuth%20flow%E2%80%94sometimes%20surfacing%20as%20state_not_found%20or%20invalid_code%20errors%2C%20even%20if%20your%20logs%20aren%27t%20explicit%20about%20the%20JWT%20problem%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/1522%29%29.%20%0A%0ARegenerate%20your%20clientSecret%20JWT%20with%20aud%3A%20%22https%3A//appleid.apple.com%22%20and%20keep%20all%20other%20claims%20as%20you%20have%20them.%20Then%20update%20your%20Better%20Auth%20config%20and%20try%20again.%20If%20you%20still%20see%20state_not_found%2C%20double-check%20that%20your%20callback%20URLs%20and%20trustedOrigins%20are%20correct%2C%20and%20that%20your%20cookies%20are%20set%20with%20SameSite%3A%20%22none%22%20and%20Secure%3A%20true%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/5227%29%29.%0A%0AIf%20you%20want%20to%20fully%20verify%20the%20JWT%2C%20you%20can%20use%20jwt.io%20to%20decode%20and%20inspect%20the%20claims%2C%20but%20signature%20verification%20requires%20the%20private%20key%20and%20Apple%27s%20public%20keys%20%28%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/7550%29%29.)&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/7034)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10708