[GH-ISSUE #4720] Next.js App Router OPTIONS Preflight Requests Not Honoring trustedOrigins for CORS #18665

Closed
opened 2026-04-15 17:13:46 -05:00 by GiteaMirror · 13 comments
Owner

Originally created by @amirhd-com on GitHub (Sep 17, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/4720

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

I am attempting to use Better Auth with a Next.js App Router setup, where my authentication server (ams.example.com) is a separate Next.js instance running Better Auth, and my client applications (example.com, ms.example.com) are also Next.js App Router instances. I have configured trustedOrigins in my Better Auth server's configuration to include all client domains.

Problem:

When authClient is invoked from a client-side context (SSR or CSR leading to browser-initiated requests) on example.com to an authentication endpoint on auth.example.com (e.g., /api/auth/phone-number/send-otp), the browser's preflight OPTIONS request receives a 204 No Content response from the ams.example.com server. However, this response critically lacks the necessary Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true CORS headers.

This leads to the browser blocking the subsequent actual request (e.g., POST /api/auth/phone-number/send-otp) with a CORS error, despite trustedOrigins being correctly configured on the server.

Specific Observations:

  1. Server-to-Server Calls Work: When using Next.js Server Actions (which initiate requests server-to-server), authentication calls to ams.example.com succeed. However, any Set-Cookie headers are not automatically forwarded to the client's browser without manual intervention, which is a separate but related concern for cross-domain cookie handling. This confirms the underlying authentication logic on ams.example.com is functional.

  2. OPTIONS Request Details:

Attempts to Resolve:

  • Ensured trustedOrigins array (e.g., ['http://localhost:6001', 'https://example.com']) is correctly configured and passed to the Better Auth instance on ams.example.com.

  • Confirmed app/api/auth/[...all]/route.ts exists on ams.example.com and delegates GET, POST to auth.handler.

  • Attempted to add export const OPTIONS = auth.handler; to the route.ts file, which resulted in a 404 Not Found error for OPTIONS requests, indicating that auth.handler might not directly support being exported as the OPTIONS handler in Next.js App Router route.ts files, or it has an internal routing mechanism that doesn't resolve for the raw OPTIONS method.

Impact:

This issue prevents the use of Better Auth's client-side library in cross-domain scenarios within Next.js App Router applications, severely limiting its utility for common SPA/MPA patterns with a dedicated auth server.

Request:

Please provide guidance or a fix for properly configuring Better Auth's server-side component within a Next.js App Router app/api/[...catchall]/route.ts to correctly handle OPTIONS preflight requests and apply CORS headers based on trustedOrigins. This seems to be a significant integration gap for Next.js App Router environments

Current vs. Expected behavior

Expected Behavior:

When a browser-initiated OPTIONS request from a trustedOrigin hits the Better Auth catch-all API route, the auth.handler (or the integrated system) should respond with the correct Access-Control-Allow-Origin (matching the request's Origin) and Access-Control-Allow-Credentials: true headers in the 204 No Content response, allowing the browser to proceed with the actual request.

What version of Better Auth are you using?

1.3.11

System info

{
  "system": {
    "platform": "win32",
    "arch": "x64",
    "version": "Windows 11 Pro",
    "release": "10.0.26100",
    "cpuCount": 8,
    "cpuModel": "11th Gen Intel(R) Core(TM) i7-11370H @ 3.30GHz",
    "totalMemory": "31.84 GB",
    "freeMemory": "10.77 GB"
  },
  "node": {
    "version": "v22.18.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.5.2"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "15.5.3"
    },
    {
      "name": "react",
      "version": "19.1.0"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.16.3"
    }
  ],
  "betterAuth": {
    "version": "^1.3.11",
    "config": {
      "database": {
        "_events": {},
        "_eventsCount": 0,
        "options": {
          "connectionString": "[REDACTED]",
          "max": 10,
          "min": 0,
          "maxUses": null,
          "allowExitOnIdle": false,
          "maxLifetimeSeconds": 0,
          "idleTimeoutMillis": 10000
        },
        "_clients": [],
        "_idle": [],
        "_expired": {},
        "_pendingQueue": [],
        "ending": false,
        "ended": false
      },
      "emailAndPassword": {
        "enabled": true
      },
      "plugins": [
        {
          "name": "admin",
          "config": {
            "id": "admin",
            "hooks": {
              "after": [
                {}
              ]
            },
            "endpoints": {},
            "$ERROR_CODES": {
              "FAILED_TO_CREATE_USER": "Failed to create user",
              "USER_ALREADY_EXISTS": "User already exists. Use another email.",
              "YOU_CANNOT_BAN_YOURSELF": "You cannot ban yourself",
              "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role",
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS": "You are not allowed to create users",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS": "You are not allowed to list users",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS": "You are not allowed to list users sessions",
              "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS": "You are not allowed to ban users",
              "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS": "You are not allowed to impersonate users",
              "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS": "You are not allowed to revoke users sessions",
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS": "You are not allowed to delete users",
              "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD": "[REDACTED]",
              "BANNED_USER": "You have been banned from this application",
              "YOU_ARE_NOT_ALLOWED_TO_GET_USER": "You are not allowed to get user",
              "NO_DATA_TO_UPDATE": "No data to update",
              "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS": "You are not allowed to update users",
              "YOU_CANNOT_REMOVE_YOURSELF": "You cannot remove yourself"
            },
            "schema": {
              "user": {
                "fields": {
                  "role": {
                    "type": "string",
                    "required": false,
                    "input": false
                  },
                  "banned": {
                    "type": "boolean",
                    "defaultValue": false,
                    "required": false,
                    "input": false
                  },
                  "banReason": {
                    "type": "string",
                    "required": false,
                    "input": false
                  },
                  "banExpires": {
                    "type": "date",
                    "required": false,
                    "input": false
                  }
                }
              },
              "session": {
                "fields": {
                  "impersonatedBy": {
                    "type": "string",
                    "required": false
                  }
                }
              }
            }
          }
        },
        {
          "name": "organization",
          "config": {
            "id": "organization",
            "endpoints": {},
            "schema": {
              "organization": {
                "fields": {
                  "name": {
                    "type": "string",
                    "required": true,
                    "sortable": true
                  },
                  "slug": {
                    "type": "string",
                    "unique": true,
                    "sortable": true
                  },
                  "logo": {
                    "type": "string",
                    "required": false
                  },
                  "createdAt": {
                    "type": "date",
                    "required": true
                  },
                  "metadata": {
                    "type": "string",
                    "required": false
                  }
                }
              },
              "member": {
                "fields": {
                  "organizationId": {
                    "type": "string",
                    "required": true,
                    "references": {
                      "model": "organization",
                      "field": "id"
                    }
                  },
                  "userId": {
                    "type": "string",
                    "required": true,
                    "references": {
                      "model": "user",
                      "field": "id"
                    }
                  },
                  "role": {
                    "type": "string",
                    "required": true,
                    "sortable": true,
                    "defaultValue": "member"
                  },
                  "createdAt": {
                    "type": "date",
                    "required": true
                  }
                }
              },
              "invitation": {
                "fields": {
                  "organizationId": {
                    "type": "string",
                    "required": true,
                    "references": {
                      "model": "organization",
                      "field": "id"
                    }
                  },
                  "email": {
                    "type": "string",
                    "required": true,
                    "sortable": true
                  },
                  "role": {
                    "type": "string",
                    "required": false,
                    "sortable": true
                  },
                  "status": {
                    "type": "string",
                    "required": true,
                    "sortable": true,
                    "defaultValue": "pending"
                  },
                  "expiresAt": {
                    "type": "date",
                    "required": true
                  },
                  "inviterId": {
                    "type": "string",
                    "references": {
                      "model": "user",
                      "field": "id"
                    },
                    "required": true
                  }
                }
              },
              "session": {
                "fields": {
                  "activeOrganizationId": {
                    "type": "string",
                    "required": false
                  }
                }
              }
            },
            "$Infer": {
              "Organization": {},
              "Invitation": {},
              "Member": {},
              "Team": {},
              "TeamMember": {},
              "ActiveOrganization": {}
            },
            "$ERROR_CODES": {
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION": "You are not allowed to create a new organization",        
              "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS": "You have reached the maximum number of organizations",
              "ORGANIZATION_ALREADY_EXISTS": "Organization already exists",
              "ORGANIZATION_NOT_FOUND": "Organization not found",
              "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "User is not a member of the organization",
              "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION": "You are not allowed to update this organization",
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION": "You are not allowed to delete this organization",
              "NO_ACTIVE_ORGANIZATION": "No active organization",
              "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION": "User is already a member of this organization",
              "MEMBER_NOT_FOUND": "Member not found",
              "ROLE_NOT_FOUND": "Role not found",
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM": "You are not allowed to create a new team",
              "TEAM_ALREADY_EXISTS": "Team already exists",
              "TEAM_NOT_FOUND": "Team not found",
              "YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER": "You cannot leave the organization as the only owner",  
              "YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER": "You cannot leave the organization without an owner",    
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER": "You are not allowed to delete this member",
              "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION": "You are not allowed to invite users to this organization",
              "USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "User is already invited to this organization",
              "INVITATION_NOT_FOUND": "Invitation not found",
              "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION": "You are not the recipient of the invitation",
              "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION": "Email verification required before accepting or rejecting invitation",
              "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION": "You are not allowed to cancel this invitation",
              "INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION": "Inviter is no longer a member of the organization",      
              "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE": "You are not allowed to invite a user with this role",    
              "FAILED_TO_RETRIEVE_INVITATION": "Failed to retrieve invitation",
              "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS": "You have reached the maximum number of teams",
              "UNABLE_TO_REMOVE_LAST_TEAM": "Unable to remove last team",
              "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER": "You are not allowed to update this member",
              "ORGANIZATION_MEMBERSHIP_LIMIT_REACHED": "Organization membership limit reached",
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION": "You are not allowed to create teams in this organization",
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION": "You are not allowed to delete teams in this organization",
              "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM": "You are not allowed to update this team",
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM": "You are not allowed to delete this team",
              "INVITATION_LIMIT_REACHED": "Invitation limit reached",
              "TEAM_MEMBER_LIMIT_REACHED": "Team member limit reached",
              "USER_IS_NOT_A_MEMBER_OF_THE_TEAM": "User is not a member of the team",
              "YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM": "You are not allowed to list the members of this team",
              "YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM": "You do not have an active team",
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER": "You are not allowed to create a new member",
              "YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER": "You are not allowed to remove a team member",
              "YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION": "You are not allowed to access this organization as an owner",
              "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION": "You are not a member of this organization",
              "MISSING_AC_INSTANCE": "Dynamic Access Control requires a pre-defined ac instance on the server auth plugin. Read server logs for more information",
              "YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE": "You must be in an organization to create a role",
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE": "You are not allowed to create a role",
              "YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE": "You are not allowed to update a role",
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE": "You are not allowed to delete a role",
              "YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE": "You are not allowed to read a role",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE": "You are not allowed to list a role",
              "YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE": "You are not allowed to get a role",
              "TOO_MANY_ROLES": "This organization has too many roles",
              "INVALID_RESOURCE": "The provided permission includes an invalid resource",
              "ROLE_NAME_IS_ALREADY_TAKEN": "That role name is already taken",
              "CANNOT_DELETE_A_PRE_DEFINED_ROLE": "Cannot delete a pre-defined role"
            }
          }
        },
        {
          "name": "multi-session",
          "config": {
            "id": "multi-session",
            "endpoints": {},
            "hooks": {
              "after": [
                {},
                {}
              ]
            },
            "$ERROR_CODES": {
              "INVALID_SESSION_TOKEN": "[REDACTED]"
            }
          }
        },
        {
          "name": "username",
          "config": {
            "id": "username",
            "endpoints": {},
            "schema": {
              "user": {
                "fields": {
                  "username": {
                    "type": "string",
                    "required": false,
                    "sortable": true,
                    "unique": true,
                    "returned": true,
                    "transform": {}
                  },
                  "displayUsername": {
                    "type": "string",
                    "required": false,
                    "transform": {}
                  }
                }
              }
            },
            "hooks": {
              "before": [
                {},
                {}
              ]
            },
            "$ERROR_CODES": {
              "INVALID_USERNAME_OR_PASSWORD": "[REDACTED]",
              "EMAIL_NOT_VERIFIED": "Email not verified",
              "UNEXPECTED_ERROR": "Unexpected error",
              "USERNAME_IS_ALREADY_TAKEN": "Username is already taken. Please try another.",
              "USERNAME_TOO_SHORT": "Username is too short",
              "USERNAME_TOO_LONG": "Username is too long",
              "INVALID_USERNAME": "Username is invalid",
              "INVALID_DISPLAY_USERNAME": "Display username is invalid"
            }
          }
        },
        {
          "name": "phone-number",
          "config": {
            "id": "phone-number",
            "endpoints": {},
            "schema": {
              "user": {
                "fields": {
                  "phoneNumber": {
                    "type": "string",
                    "required": false,
                    "unique": true,
                    "sortable": true,
                    "returned": true
                  },
                  "phoneNumberVerified": {
                    "type": "boolean",
                    "required": false,
                    "returned": true,
                    "input": false
                  }
                }
              }
            },
            "rateLimit": [
              {
                "window": 60000,
                "max": 10
              }
            ],
            "$ERROR_CODES": {
              "INVALID_PHONE_NUMBER": "Invalid phone number",
              "PHONE_NUMBER_EXIST": "Phone number already exists",
              "INVALID_PHONE_NUMBER_OR_PASSWORD": "[REDACTED]",
              "UNEXPECTED_ERROR": "Unexpected error",
              "OTP_NOT_FOUND": "OTP not found",
              "OTP_EXPIRED": "OTP expired",
              "INVALID_OTP": "Invalid OTP",
              "PHONE_NUMBER_NOT_VERIFIED": "Phone number not verified"
            }
          }
        },
        {
          "name": "next-cookies",
          "config": {
            "id": "next-cookies",
            "hooks": {
              "after": [
                {}
              ]
            }
          }
        }
      ],
      "baseURL": "https://ams.example.com",
      "secret": "[REDACTED]",
      "advanced": {
        "cookiePrefix": "example",
        "useSecureCookies": false,
        "crossSubDomainCookies": {
          "enabled": true,
          "domain": "localhost || .example.com"
        }
      },
      "trustedOrigins": [
        "http://localhost:6001",
        "https://example.com",
        "https://ms.example.com"
      ]
    }
  }
}

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

Backend, Client, Types, Documentation

Auth config (if applicable)

import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import {
  admin,
  phoneNumber,
  organization,
  multiSession,
  username,
} from "better-auth/plugins";
import { Pool } from "pg";

export const auth = betterAuth({
  database: new Pool({
    connectionString:
      process.env.NODE_ENV === "production"
        ? process.env.DATABASE_URL
        : process.env.DEV_DATABASE_URL,
  }),
  emailAndPassword: {
    enabled: true,
  },
  plugins: [
    admin(),
    organization(),
    multiSession(),
    username(),
    phoneNumber({
      sendOTP: async ({ phoneNumber, code }) => {
        try {
          // Convert Persian/Farsi numerals to standard English numerals if needed

          // Using HTTP POST method as shown in the documentation
          const response = await fetch(
            process.env.SMS_PROVIDER_API_URL as string,
            {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
                Accept: "text/plain",
                "X-Api-Key": process.env.SMS_PROVIDER_API_KEY as string,
              },
              body: JSON.stringify({
                mobile: phoneNumber,
                templateId: process.env.SMS_AUTH_PATTERN_CODE as string,
                parameters: [
                  {
                    name: "CODE",
                    value: `${code}`,
                  },
                ],
              }),
            }
          );

          const data = await response.text(); // API returns XML
          console.log("SMS service response:", data);

          // You may want to parse the XML response
          // If successful, the response will contain a string with a value > 15 digits

          // return data;
        } catch (error) {
          console.error("Error sending SMS:", error);
          throw new Error("Failed to send verification code");
        }
      },
      signUpOnVerification: {
        getTempEmail: (phoneNumber) => {
          return `${phoneNumber}@example.com`;
        },
        //optionally, you can also pass `getTempName` function to generate a temporary name for the user
        getTempName: (phoneNumber) => {
          return phoneNumber; //by default, it will use the phone number as the name
        },
      },
      //   callbackOnVerification: async ({ phoneNumber, user }) => {
      //
      //   },
      otpLength: 4,
      expiresIn: 60 * 2,
      requireVerification: true,
    }),
    nextCookies(),
  ],
  baseURL:
    process.env.NODE_ENV === "development"
      ? (process.env.DEV_AUTH_URL as string)
      : (process.env.AUTH_URL as string),
  secret:
    process.env.NODE_ENV === "development"
      ? (process.env.DEV_AUTH_SECRET as string)
      : (process.env.AUTH_SECRET as string),
  advanced: {
    cookiePrefix: "example",
    useSecureCookies: process.env.NODE_ENV === "production" ? true : false,
    crossSubDomainCookies: {
      enabled: true,
      //   additionalCookies: [],
      domain:
        process.env.NODE_ENV === "development"
          ? (process.env.DEV_CROSS_SUBDOMAIN as string)
          : (process.env.CROSS_SUBDOMAIN as string),
    },
  },

  trustedOrigins: [
    process.env.NODE_ENV === "development"
      ? (process.env.DEV_WEBAPP_ORIGIN_URL as string,
        process.env.DEV_MS_ORIGIN_URL as string,
        process.env.DEV_AUTH_URL as string)
      : (process.env.WEBAPP_ORIGIN_URL as string,
        process.env.MS_ORIGIN_URL as string,
        process.env.AUTH_URL as string),
  ],
});

Additional context

auth client config in client instance on nextjs app router:

import { createAuthClient } from "better-auth/react";
import {
phoneNumberClient,
} from "better-auth/client/plugins";

export const authClient = createAuthClient({
baseURL:
process.env.NODE_ENV === "development"
? process.env.NEXT_PUBLIC_DEV_AUTH_URL
: process.env.NEXT_PUBLIC_AUTH_URL,
plugins: [phoneNumberClient()],
fetchOptions: {
credentials: "include",
},
});

Originally created by @amirhd-com on GitHub (Sep 17, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/4720 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce I am attempting to use Better Auth with a Next.js App Router setup, where my authentication server (ams.example.com) is a separate Next.js instance running Better Auth, and my client applications (example.com, ms.example.com) are also Next.js App Router instances. I have configured trustedOrigins in my Better Auth server's configuration to include all client domains. Problem: When authClient is invoked from a client-side context (SSR or CSR leading to browser-initiated requests) on example.com to an authentication endpoint on auth.example.com (e.g., /api/auth/phone-number/send-otp), the browser's preflight OPTIONS request receives a 204 No Content response from the ams.example.com server. However, this response critically lacks the necessary Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true CORS headers. This leads to the browser blocking the subsequent actual request (e.g., POST /api/auth/phone-number/send-otp) with a CORS error, despite trustedOrigins being correctly configured on the server. Specific Observations: 1. Server-to-Server Calls Work: When using Next.js Server Actions (which initiate requests server-to-server), authentication calls to ams.example.com succeed. However, any Set-Cookie headers are not automatically forwarded to the client's browser without manual intervention, which is a separate but related concern for cross-domain cookie handling. This confirms the underlying authentication logic on ams.example.com is functional. 2. OPTIONS Request Details: - Request Origin: http://localhost:6001 (dev) / https://example.com (prod) - Request Method: OPTIONS - Request URL: http://localhost:7000/api/auth/phone-number/send-otp (dev) / https://auth.example.com/api/auth/phone-number/send-otp (prod) - Response Status: 204 No Content - Critical Missing Headers: Access-Control-Allow-Origin, Access-Control-Allow-Credentials: true, Access-Control-Allow-Headers. - The Allow: GET, HEAD, OPTIONS, POST header is present, but this is insufficient for browser CORS checks. Attempts to Resolve: - Ensured trustedOrigins array (e.g., ['http://localhost:6001', 'https://example.com']) is correctly configured and passed to the Better Auth instance on ams.example.com. - Confirmed app/api/auth/[...all]/route.ts exists on ams.example.com and delegates GET, POST to auth.handler. - Attempted to add export const OPTIONS = auth.handler; to the route.ts file, which resulted in a 404 Not Found error for OPTIONS requests, indicating that auth.handler might not directly support being exported as the OPTIONS handler in Next.js App Router route.ts files, or it has an internal routing mechanism that doesn't resolve for the raw OPTIONS method. Impact: This issue prevents the use of Better Auth's client-side library in cross-domain scenarios within Next.js App Router applications, severely limiting its utility for common SPA/MPA patterns with a dedicated auth server. Request: Please provide guidance or a fix for properly configuring Better Auth's server-side component within a Next.js App Router app/api/[...catchall]/route.ts to correctly handle OPTIONS preflight requests and apply CORS headers based on trustedOrigins. This seems to be a significant integration gap for Next.js App Router environments ### Current vs. Expected behavior Expected Behavior: When a browser-initiated OPTIONS request from a trustedOrigin hits the Better Auth catch-all API route, the auth.handler (or the integrated system) should respond with the correct Access-Control-Allow-Origin (matching the request's Origin) and Access-Control-Allow-Credentials: true headers in the 204 No Content response, allowing the browser to proceed with the actual request. ### What version of Better Auth are you using? 1.3.11 ### System info ```bash { "system": { "platform": "win32", "arch": "x64", "version": "Windows 11 Pro", "release": "10.0.26100", "cpuCount": 8, "cpuModel": "11th Gen Intel(R) Core(TM) i7-11370H @ 3.30GHz", "totalMemory": "31.84 GB", "freeMemory": "10.77 GB" }, "node": { "version": "v22.18.0", "env": "development" }, "packageManager": { "name": "npm", "version": "11.5.2" }, "frameworks": [ { "name": "next", "version": "15.5.3" }, { "name": "react", "version": "19.1.0" } ], "databases": [ { "name": "pg", "version": "^8.16.3" } ], "betterAuth": { "version": "^1.3.11", "config": { "database": { "_events": {}, "_eventsCount": 0, "options": { "connectionString": "[REDACTED]", "max": 10, "min": 0, "maxUses": null, "allowExitOnIdle": false, "maxLifetimeSeconds": 0, "idleTimeoutMillis": 10000 }, "_clients": [], "_idle": [], "_expired": {}, "_pendingQueue": [], "ending": false, "ended": false }, "emailAndPassword": { "enabled": true }, "plugins": [ { "name": "admin", "config": { "id": "admin", "hooks": { "after": [ {} ] }, "endpoints": {}, "$ERROR_CODES": { "FAILED_TO_CREATE_USER": "Failed to create user", "USER_ALREADY_EXISTS": "User already exists. Use another email.", "YOU_CANNOT_BAN_YOURSELF": "You cannot ban yourself", "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS": "You are not allowed to create users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS": "You are not allowed to list users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS": "You are not allowed to list users sessions", "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS": "You are not allowed to ban users", "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS": "You are not allowed to impersonate users", "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS": "You are not allowed to revoke users sessions", "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS": "You are not allowed to delete users", "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD": "[REDACTED]", "BANNED_USER": "You have been banned from this application", "YOU_ARE_NOT_ALLOWED_TO_GET_USER": "You are not allowed to get user", "NO_DATA_TO_UPDATE": "No data to update", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS": "You are not allowed to update users", "YOU_CANNOT_REMOVE_YOURSELF": "You cannot remove yourself" }, "schema": { "user": { "fields": { "role": { "type": "string", "required": false, "input": false }, "banned": { "type": "boolean", "defaultValue": false, "required": false, "input": false }, "banReason": { "type": "string", "required": false, "input": false }, "banExpires": { "type": "date", "required": false, "input": false } } }, "session": { "fields": { "impersonatedBy": { "type": "string", "required": false } } } } } }, { "name": "organization", "config": { "id": "organization", "endpoints": {}, "schema": { "organization": { "fields": { "name": { "type": "string", "required": true, "sortable": true }, "slug": { "type": "string", "unique": true, "sortable": true }, "logo": { "type": "string", "required": false }, "createdAt": { "type": "date", "required": true }, "metadata": { "type": "string", "required": false } } }, "member": { "fields": { "organizationId": { "type": "string", "required": true, "references": { "model": "organization", "field": "id" } }, "userId": { "type": "string", "required": true, "references": { "model": "user", "field": "id" } }, "role": { "type": "string", "required": true, "sortable": true, "defaultValue": "member" }, "createdAt": { "type": "date", "required": true } } }, "invitation": { "fields": { "organizationId": { "type": "string", "required": true, "references": { "model": "organization", "field": "id" } }, "email": { "type": "string", "required": true, "sortable": true }, "role": { "type": "string", "required": false, "sortable": true }, "status": { "type": "string", "required": true, "sortable": true, "defaultValue": "pending" }, "expiresAt": { "type": "date", "required": true }, "inviterId": { "type": "string", "references": { "model": "user", "field": "id" }, "required": true } } }, "session": { "fields": { "activeOrganizationId": { "type": "string", "required": false } } } }, "$Infer": { "Organization": {}, "Invitation": {}, "Member": {}, "Team": {}, "TeamMember": {}, "ActiveOrganization": {} }, "$ERROR_CODES": { "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION": "You are not allowed to create a new organization", "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS": "You have reached the maximum number of organizations", "ORGANIZATION_ALREADY_EXISTS": "Organization already exists", "ORGANIZATION_NOT_FOUND": "Organization not found", "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION": "User is not a member of the organization", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION": "You are not allowed to update this organization", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION": "You are not allowed to delete this organization", "NO_ACTIVE_ORGANIZATION": "No active organization", "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION": "User is already a member of this organization", "MEMBER_NOT_FOUND": "Member not found", "ROLE_NOT_FOUND": "Role not found", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM": "You are not allowed to create a new team", "TEAM_ALREADY_EXISTS": "Team already exists", "TEAM_NOT_FOUND": "Team not found", "YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER": "You cannot leave the organization as the only owner", "YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER": "You cannot leave the organization without an owner", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER": "You are not allowed to delete this member", "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION": "You are not allowed to invite users to this organization", "USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION": "User is already invited to this organization", "INVITATION_NOT_FOUND": "Invitation not found", "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION": "You are not the recipient of the invitation", "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION": "Email verification required before accepting or rejecting invitation", "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION": "You are not allowed to cancel this invitation", "INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION": "Inviter is no longer a member of the organization", "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE": "You are not allowed to invite a user with this role", "FAILED_TO_RETRIEVE_INVITATION": "Failed to retrieve invitation", "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS": "You have reached the maximum number of teams", "UNABLE_TO_REMOVE_LAST_TEAM": "Unable to remove last team", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER": "You are not allowed to update this member", "ORGANIZATION_MEMBERSHIP_LIMIT_REACHED": "Organization membership limit reached", "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION": "You are not allowed to create teams in this organization", "YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION": "You are not allowed to delete teams in this organization", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM": "You are not allowed to update this team", "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM": "You are not allowed to delete this team", "INVITATION_LIMIT_REACHED": "Invitation limit reached", "TEAM_MEMBER_LIMIT_REACHED": "Team member limit reached", "USER_IS_NOT_A_MEMBER_OF_THE_TEAM": "User is not a member of the team", "YOU_CAN_NOT_ACCESS_THE_MEMBERS_OF_THIS_TEAM": "You are not allowed to list the members of this team", "YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM": "You do not have an active team", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER": "You are not allowed to create a new member", "YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER": "You are not allowed to remove a team member", "YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION": "You are not allowed to access this organization as an owner", "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION": "You are not a member of this organization", "MISSING_AC_INSTANCE": "Dynamic Access Control requires a pre-defined ac instance on the server auth plugin. Read server logs for more information", "YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE": "You must be in an organization to create a role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE": "You are not allowed to create a role", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE": "You are not allowed to update a role", "YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE": "You are not allowed to delete a role", "YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE": "You are not allowed to read a role", "YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE": "You are not allowed to list a role", "YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE": "You are not allowed to get a role", "TOO_MANY_ROLES": "This organization has too many roles", "INVALID_RESOURCE": "The provided permission includes an invalid resource", "ROLE_NAME_IS_ALREADY_TAKEN": "That role name is already taken", "CANNOT_DELETE_A_PRE_DEFINED_ROLE": "Cannot delete a pre-defined role" } } }, { "name": "multi-session", "config": { "id": "multi-session", "endpoints": {}, "hooks": { "after": [ {}, {} ] }, "$ERROR_CODES": { "INVALID_SESSION_TOKEN": "[REDACTED]" } } }, { "name": "username", "config": { "id": "username", "endpoints": {}, "schema": { "user": { "fields": { "username": { "type": "string", "required": false, "sortable": true, "unique": true, "returned": true, "transform": {} }, "displayUsername": { "type": "string", "required": false, "transform": {} } } } }, "hooks": { "before": [ {}, {} ] }, "$ERROR_CODES": { "INVALID_USERNAME_OR_PASSWORD": "[REDACTED]", "EMAIL_NOT_VERIFIED": "Email not verified", "UNEXPECTED_ERROR": "Unexpected error", "USERNAME_IS_ALREADY_TAKEN": "Username is already taken. Please try another.", "USERNAME_TOO_SHORT": "Username is too short", "USERNAME_TOO_LONG": "Username is too long", "INVALID_USERNAME": "Username is invalid", "INVALID_DISPLAY_USERNAME": "Display username is invalid" } } }, { "name": "phone-number", "config": { "id": "phone-number", "endpoints": {}, "schema": { "user": { "fields": { "phoneNumber": { "type": "string", "required": false, "unique": true, "sortable": true, "returned": true }, "phoneNumberVerified": { "type": "boolean", "required": false, "returned": true, "input": false } } } }, "rateLimit": [ { "window": 60000, "max": 10 } ], "$ERROR_CODES": { "INVALID_PHONE_NUMBER": "Invalid phone number", "PHONE_NUMBER_EXIST": "Phone number already exists", "INVALID_PHONE_NUMBER_OR_PASSWORD": "[REDACTED]", "UNEXPECTED_ERROR": "Unexpected error", "OTP_NOT_FOUND": "OTP not found", "OTP_EXPIRED": "OTP expired", "INVALID_OTP": "Invalid OTP", "PHONE_NUMBER_NOT_VERIFIED": "Phone number not verified" } } }, { "name": "next-cookies", "config": { "id": "next-cookies", "hooks": { "after": [ {} ] } } } ], "baseURL": "https://ams.example.com", "secret": "[REDACTED]", "advanced": { "cookiePrefix": "example", "useSecureCookies": false, "crossSubDomainCookies": { "enabled": true, "domain": "localhost || .example.com" } }, "trustedOrigins": [ "http://localhost:6001", "https://example.com", "https://ms.example.com" ] } } } ``` ### Which area(s) are affected? (Select all that apply) Backend, Client, Types, Documentation ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth"; import { nextCookies } from "better-auth/next-js"; import { admin, phoneNumber, organization, multiSession, username, } from "better-auth/plugins"; import { Pool } from "pg"; export const auth = betterAuth({ database: new Pool({ connectionString: process.env.NODE_ENV === "production" ? process.env.DATABASE_URL : process.env.DEV_DATABASE_URL, }), emailAndPassword: { enabled: true, }, plugins: [ admin(), organization(), multiSession(), username(), phoneNumber({ sendOTP: async ({ phoneNumber, code }) => { try { // Convert Persian/Farsi numerals to standard English numerals if needed // Using HTTP POST method as shown in the documentation const response = await fetch( process.env.SMS_PROVIDER_API_URL as string, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/plain", "X-Api-Key": process.env.SMS_PROVIDER_API_KEY as string, }, body: JSON.stringify({ mobile: phoneNumber, templateId: process.env.SMS_AUTH_PATTERN_CODE as string, parameters: [ { name: "CODE", value: `${code}`, }, ], }), } ); const data = await response.text(); // API returns XML console.log("SMS service response:", data); // You may want to parse the XML response // If successful, the response will contain a string with a value > 15 digits // return data; } catch (error) { console.error("Error sending SMS:", error); throw new Error("Failed to send verification code"); } }, signUpOnVerification: { getTempEmail: (phoneNumber) => { return `${phoneNumber}@example.com`; }, //optionally, you can also pass `getTempName` function to generate a temporary name for the user getTempName: (phoneNumber) => { return phoneNumber; //by default, it will use the phone number as the name }, }, // callbackOnVerification: async ({ phoneNumber, user }) => { // // }, otpLength: 4, expiresIn: 60 * 2, requireVerification: true, }), nextCookies(), ], baseURL: process.env.NODE_ENV === "development" ? (process.env.DEV_AUTH_URL as string) : (process.env.AUTH_URL as string), secret: process.env.NODE_ENV === "development" ? (process.env.DEV_AUTH_SECRET as string) : (process.env.AUTH_SECRET as string), advanced: { cookiePrefix: "example", useSecureCookies: process.env.NODE_ENV === "production" ? true : false, crossSubDomainCookies: { enabled: true, // additionalCookies: [], domain: process.env.NODE_ENV === "development" ? (process.env.DEV_CROSS_SUBDOMAIN as string) : (process.env.CROSS_SUBDOMAIN as string), }, }, trustedOrigins: [ process.env.NODE_ENV === "development" ? (process.env.DEV_WEBAPP_ORIGIN_URL as string, process.env.DEV_MS_ORIGIN_URL as string, process.env.DEV_AUTH_URL as string) : (process.env.WEBAPP_ORIGIN_URL as string, process.env.MS_ORIGIN_URL as string, process.env.AUTH_URL as string), ], }); ``` ### Additional context auth client config in client instance on nextjs app router: import { createAuthClient } from "better-auth/react"; import { phoneNumberClient, } from "better-auth/client/plugins"; export const authClient = createAuthClient({ baseURL: process.env.NODE_ENV === "development" ? process.env.NEXT_PUBLIC_DEV_AUTH_URL : process.env.NEXT_PUBLIC_AUTH_URL, plugins: [phoneNumberClient()], fetchOptions: { credentials: "include", }, });
GiteaMirror added the locked label 2026-04-15 17:13:46 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Sep 17, 2025):

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

This is a known integration gap: Better Auth does not automatically set CORS headers for Next.js App Router handlers, even if you configure trustedOrigins—that setting is only used for internal checks, not for CORS response headers. As a result, browser preflight (OPTIONS) requests will not include the required Access-Control-Allow-Origin and Access-Control-Allow-Credentials headers unless you add them yourself, which causes the CORS errors you’re seeing [1].

The recommended workaround is to implement explicit CORS handling in your /api/auth route. You can do this by creating a CORS middleware that checks the Origin header against your allowed origins (matching your trustedOrigins), sets the necessary CORS headers, and wraps your Better Auth handlers. Here’s a minimal example:

const allowedOrigins = new Set([
  'http://localhost:6001',
  'https://example.com',
  'https://ms.example.com'
]);

function withCors(handler) {
  return async (req, ...args) => {
    const origin = req.headers.get('origin');
    const headers = {
      'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Access-Control-Allow-Credentials': 'true'
    };
    if (origin && allowedOrigins.has(origin)) {
      headers['Access-Control-Allow-Origin'] = origin;
    }
    if (req.method === 'OPTIONS') {
      return new Response(null, { status: 204, headers });
    }
    const res = await handler(req, ...args);
    Object.entries(headers).forEach(([k, v]) => res.headers.set(k, v));
    return res;
  };
}

// Usage in your route.ts:
export const GET = withCors(auth.handler.GET);
export const POST = withCors(auth.handler.POST);
export const OPTIONS = withCors(() => new Response(null, { status: 204 }));

This ensures all responses (including OPTIONS) have the correct CORS headers, allowing browser requests to succeed [2].

If you use cross-origin cookies, make sure your cookie settings are compatible (sameSite: 'none', secure: true, and credentials: 'include' in fetch) [3].

References:

To reply, just mention @dosu.


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

<!-- gh-comment-id:3302800773 --> @dosubot[bot] commented on GitHub (Sep 17, 2025): <!-- Greeting --> Hi @amirhd-com! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known integration gap: Better Auth does not automatically set CORS headers for Next.js App Router handlers, even if you configure `trustedOrigins`—that setting is only used for internal checks, not for CORS response headers. As a result, browser preflight (OPTIONS) requests will not include the required `Access-Control-Allow-Origin` and `Access-Control-Allow-Credentials` headers unless you add them yourself, which causes the CORS errors you’re seeing [[1]](https://github.com/better-auth/better-auth/issues/4343). The recommended workaround is to implement explicit CORS handling in your `/api/auth` route. You can do this by creating a CORS middleware that checks the `Origin` header against your allowed origins (matching your `trustedOrigins`), sets the necessary CORS headers, and wraps your Better Auth handlers. Here’s a minimal example: ```typescript const allowedOrigins = new Set([ 'http://localhost:6001', 'https://example.com', 'https://ms.example.com' ]); function withCors(handler) { return async (req, ...args) => { const origin = req.headers.get('origin'); const headers = { 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Credentials': 'true' }; if (origin && allowedOrigins.has(origin)) { headers['Access-Control-Allow-Origin'] = origin; } if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers }); } const res = await handler(req, ...args); Object.entries(headers).forEach(([k, v]) => res.headers.set(k, v)); return res; }; } // Usage in your route.ts: export const GET = withCors(auth.handler.GET); export const POST = withCors(auth.handler.POST); export const OPTIONS = withCors(() => new Response(null, { status: 204 })); ``` This ensures all responses (including OPTIONS) have the correct CORS headers, allowing browser requests to succeed [[2]](https://github.com/better-auth/better-auth/issues/4052). If you use cross-origin cookies, make sure your cookie settings are compatible (`sameSite: 'none'`, `secure: true`, and `credentials: 'include'` in fetch) [[3]](https://github.com/better-auth/better-auth/issues/3874). References: - [CORS headers not preserved in Next.js handlers](https://github.com/better-auth/better-auth/issues/4343) - [Explicit CORS workaround for Better Auth](https://github.com/better-auth/better-auth/issues/4052) - [trustedOrigins and CORS header matching](https://github.com/better-auth/better-auth/issues/3874) <!-- 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/82b7ea6c-d6c8-46a2-8e33-6005ba1bdf87?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/82b7ea6c-d6c8-46a2-8e33-6005ba1bdf87?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/82b7ea6c-d6c8-46a2-8e33-6005ba1bdf87?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/82b7ea6c-d6c8-46a2-8e33-6005ba1bdf87?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/82b7ea6c-d6c8-46a2-8e33-6005ba1bdf87?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/82b7ea6c-d6c8-46a2-8e33-6005ba1bdf87?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/82b7ea6c-d6c8-46a2-8e33-6005ba1bdf87?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4720)
Author
Owner

@amirhd-com commented on GitHub (Sep 17, 2025):

but I need cross origin automatically set cookies when get request from client better auth instance, without redirecting.

<!-- gh-comment-id:3302817992 --> @amirhd-com commented on GitHub (Sep 17, 2025): but I need cross origin automatically set cookies when get request from client better auth instance, without redirecting.
Author
Owner

@dosubot[bot] commented on GitHub (Sep 17, 2025):

You can now get automatic cross-origin cookie setting (including on GET requests, without redirects) with Better Auth, but you need to use the new advanced.crossOriginCookies config and ensure your CORS and cookie settings are correct.

Here's what you need:

  • Use Better Auth ≥1.3.11 with the latest updates (crossOriginCookies support was merged recently).
  • In your Better Auth config, set:
    advanced: {
      crossOriginCookies: { enabled: true },
      defaultCookieAttributes: {
        sameSite: "none",
        secure: true
      }
    }
    
  • Your API server must serve over HTTPS (required for SameSite=None + Secure cookies).
  • Your CORS middleware must echo the request's Origin in Access-Control-Allow-Origin, set Access-Control-Allow-Credentials: true, and allow credentials in fetch on the client.
  • Do not set a Domain attribute for cross-origin cookies (only for cross-subdomain).

With this setup, when your client makes a GET (or POST) request with credentials: 'include', the browser will accept Set-Cookie from the auth server and store the session cookie, no redirect needed. This is confirmed by recent end-to-end tests and code changes in Better Auth (PR #4482).

If you still see issues, double-check that:

  • Your CORS headers are present on every response (including OPTIONS).
  • The cookie attributes are exactly SameSite=None and Secure.
  • You are not setting a Domain attribute for true cross-origin (different root domains).

See the PR details and example for a working HTTPS server and config. This approach works for both GET and POST, and does not require redirects.

To reply, just mention @dosu.


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

<!-- gh-comment-id:3302832741 --> @dosubot[bot] commented on GitHub (Sep 17, 2025): <!-- Answer --> You can now get automatic cross-origin cookie setting (including on GET requests, without redirects) with Better Auth, but you need to use the new advanced.crossOriginCookies config and ensure your CORS and cookie settings are correct. Here's what you need: - Use Better Auth ≥1.3.11 with the latest updates (crossOriginCookies support was merged recently). - In your Better Auth config, set: ```js advanced: { crossOriginCookies: { enabled: true }, defaultCookieAttributes: { sameSite: "none", secure: true } } ``` - Your API server must serve over HTTPS (required for SameSite=None + Secure cookies). - Your CORS middleware must echo the request's Origin in Access-Control-Allow-Origin, set Access-Control-Allow-Credentials: true, and allow credentials in fetch on the client. - Do not set a Domain attribute for cross-origin cookies (only for cross-subdomain). With this setup, when your client makes a GET (or POST) request with credentials: 'include', the browser will accept Set-Cookie from the auth server and store the session cookie, no redirect needed. This is confirmed by recent end-to-end tests and code changes in Better Auth ([PR #4482](https://github.com/better-auth/better-auth/pull/4482)). If you still see issues, double-check that: - Your CORS headers are present on every response (including OPTIONS). - The cookie attributes are exactly SameSite=None and Secure. - You are not setting a Domain attribute for true cross-origin (different root domains). See the [PR details and example](https://github.com/better-auth/better-auth/pull/4482) for a working HTTPS server and config. This approach works for both GET and POST, and does not require redirects. <!-- 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/2172ee36-7b5a-4843-b2ba-fc8760120af0?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/2172ee36-7b5a-4843-b2ba-fc8760120af0?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/2172ee36-7b5a-4843-b2ba-fc8760120af0?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/2172ee36-7b5a-4843-b2ba-fc8760120af0?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/2172ee36-7b5a-4843-b2ba-fc8760120af0?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/2172ee36-7b5a-4843-b2ba-fc8760120af0?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/2172ee36-7b5a-4843-b2ba-fc8760120af0?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4720)
Author
Owner

@himself65 commented on GitHub (Sep 17, 2025):

Sorry to say, but right now better-auth doesn't set up the CORS header for you. You might need to check outthe next.js document to see how to make that

<!-- gh-comment-id:3304502155 --> @himself65 commented on GitHub (Sep 17, 2025): Sorry to say, but right now better-auth doesn't set up the CORS header for you. You might need to check outthe next.js document to see how to make that
Author
Owner

@hussion commented on GitHub (Sep 23, 2025):

The same problem occurred, and when I set crossOriginCookies and defaultCookieAttributes, the login could no longer be successful. @himself65 @dosu

<!-- gh-comment-id:3323019544 --> @hussion commented on GitHub (Sep 23, 2025): The same problem occurred, and when I set crossOriginCookies and defaultCookieAttributes, the login could no longer be successful. @himself65 @dosu
Author
Owner

@dosubot[bot] commented on GitHub (Sep 23, 2025):

When login fails after enabling crossOriginCookies and defaultCookieAttributes, it's almost always due to one of these issues:

  1. CORS headers are missing or incorrect: Your server must set Access-Control-Allow-Origin (matching the request's Origin), Access-Control-Allow-Credentials: true, and allow the necessary methods and headers on every response—including OPTIONS and all auth endpoints. If these are missing, the browser will silently block cookies and authentication will fail, even if your Better Auth config is correct. See example

  2. Cookie attributes are not set for cross-origin: For true cross-origin (different root domains), your session cookie must have SameSite=None and Secure (and must not have a Domain attribute). If you set a Domain, the browser will not store the cookie. See test and docs

  3. HTTPS is required: Browsers will only accept SameSite=None; Secure cookies over HTTPS. If your API server is not running with a valid HTTPS certificate, cookies will not be set and login will fail. See example

  4. Ambiguous config: Enabling both crossSubDomainCookies and crossOriginCookies can cause ambiguous behavior. For cross-origin, do not set a Domain. For cross-subdomain, set Domain to .yourdomain.com.

  5. Local development: If testing on localhost, set allowLocalhostUnsecure: true and secure: false in defaultCookieAttributes to allow cookies without Secure.

Checklist:

  • Server is HTTPS (not just the frontend).
  • CORS headers are present on every response, including OPTIONS.
  • defaultCookieAttributes includes sameSite: 'none', secure: true (or false for localhost with allowLocalhostUnsecure: true).
  • No Domain attribute for cross-origin cookies.
  • Client fetch uses credentials: 'include'.

If you still have issues, check your browser's network tab for missing CORS headers or Set-Cookie, and confirm your server is actually sending them. If you can share your exact Better Auth config and a sample response (headers) from your auth endpoint, I can help debug further.

References:

To reply, just mention @dosu.


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

<!-- gh-comment-id:3323033939 --> @dosubot[bot] commented on GitHub (Sep 23, 2025): <!-- Answer --> When login fails after enabling `crossOriginCookies` and `defaultCookieAttributes`, it's almost always due to one of these issues: 1. **CORS headers are missing or incorrect**: Your server must set `Access-Control-Allow-Origin` (matching the request's Origin), `Access-Control-Allow-Credentials: true`, and allow the necessary methods and headers on *every* response—including OPTIONS and all auth endpoints. If these are missing, the browser will silently block cookies and authentication will fail, even if your Better Auth config is correct. [See example](https://github.com/better-auth/better-auth/pull/4482) 2. **Cookie attributes are not set for cross-origin**: For true cross-origin (different root domains), your session cookie must have `SameSite=None` and `Secure` (and *must not* have a Domain attribute). If you set a Domain, the browser will not store the cookie. [See test and docs](https://github.com/better-auth/better-auth/pull/4572) 3. **HTTPS is required**: Browsers will only accept `SameSite=None; Secure` cookies over HTTPS. If your API server is not running with a valid HTTPS certificate, cookies will not be set and login will fail. [See example](https://github.com/better-auth/better-auth/pull/4482) 4. **Ambiguous config**: Enabling both `crossSubDomainCookies` and `crossOriginCookies` can cause ambiguous behavior. For cross-origin, do *not* set a Domain. For cross-subdomain, set Domain to `.yourdomain.com`. 5. **Local development**: If testing on localhost, set `allowLocalhostUnsecure: true` and `secure: false` in `defaultCookieAttributes` to allow cookies without Secure. **Checklist:** - Server is HTTPS (not just the frontend). - CORS headers are present on every response, including OPTIONS. - `defaultCookieAttributes` includes `sameSite: 'none'`, `secure: true` (or `false` for localhost with `allowLocalhostUnsecure: true`). - No Domain attribute for cross-origin cookies. - Client fetch uses `credentials: 'include'`. If you still have issues, check your browser's network tab for missing CORS headers or Set-Cookie, and confirm your server is actually sending them. If you can share your exact Better Auth config and a sample response (headers) from your auth endpoint, I can help debug further. References: - [PR #4482: Cross-origin cookie support, CORS, and HTTPS requirements](https://github.com/better-auth/better-auth/pull/4482) - [PR #4572: Cookie attribute inheritance and cross-origin tests](https://github.com/better-auth/better-auth/pull/4572) - [PR #4325: Cross-domain login test and CORS header handling](https://github.com/better-auth/better-auth/pull/4325) <!-- 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/1f3bc256-9961-4bdb-9b82-e901e1c89ab3?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/1f3bc256-9961-4bdb-9b82-e901e1c89ab3?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/1f3bc256-9961-4bdb-9b82-e901e1c89ab3?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/1f3bc256-9961-4bdb-9b82-e901e1c89ab3?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/1f3bc256-9961-4bdb-9b82-e901e1c89ab3?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/1f3bc256-9961-4bdb-9b82-e901e1c89ab3?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/1f3bc256-9961-4bdb-9b82-e901e1c89ab3?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4720)
Author
Owner

@StrinGhost commented on GitHub (Oct 24, 2025):

For me the problem occurred, after i update the packages. After reverting the changes my app was working properly again.

<!-- gh-comment-id:3440974357 --> @StrinGhost commented on GitHub (Oct 24, 2025): For me the problem occurred, after i update the packages. After reverting the changes my app was working properly again.
Author
Owner

@parajis commented on GitHub (Nov 1, 2025):

For me the problem occurred, after i update the packages. After reverting the changes my app was working properly again.

what version is stable for you ?

<!-- gh-comment-id:3476186359 --> @parajis commented on GitHub (Nov 1, 2025): > For me the problem occurred, after i update the packages. After reverting the changes my app was working properly again. what version is stable for you ?
Author
Owner

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

Thanks for the details! Could you share a minimal reproduction (env, exact commands, versions, and full traceback if any)? We'll investigate and circle back.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3476186876 --> @better-auth-agent[bot] commented on GitHub (Nov 1, 2025): Thanks for the details! Could you share a minimal reproduction (env, exact commands, versions, and full traceback if any)? We'll investigate and circle back. <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@StrinGhost commented on GitHub (Nov 4, 2025):

@parajis v1.3.27

<!-- gh-comment-id:3484991137 --> @StrinGhost commented on GitHub (Nov 4, 2025): @parajis v1.3.27
Author
Owner

@oxedom commented on GitHub (Nov 6, 2025):

Just wanted to follow up and share the solution in case it helps others facing a similar issue.

The root cause turned out to be on the frontend side. I had my button wrapped in a form element, and each click was triggering a form submission that redirected to domain/?. This was causing the CORS requests to fail, which initially made me think it was a server configuration problem.

Solution: Added e.preventDefault() to the button's click handler to prevent the default form submission behavior.
It was one of those subtle issues that's easy to overlook but can cause quite a bit of confusion. Hopefully this saves someone else some debugging time!

<!-- gh-comment-id:3499490790 --> @oxedom commented on GitHub (Nov 6, 2025): Just wanted to follow up and share the solution in case it helps others facing a similar issue. The root cause turned out to be on the frontend side. I had my button wrapped in a form element, and each click was triggering a form submission that redirected to domain/?. This was causing the CORS requests to fail, which initially made me think it was a server configuration problem. Solution: Added e.preventDefault() to the button's click handler to prevent the default form submission behavior. It was one of those subtle issues that's easy to overlook but can cause quite a bit of confusion. Hopefully this saves someone else some debugging time!
Author
Owner

@banuragaxioned commented on GitHub (Nov 27, 2025):

In case it helps anyone else ..

I was having a similar issue (kept seeing 204 OPTIONS) after I upgraded to 1.4.x (and to Next 16 at the same time). This wasn't the case before the upgrades.

I'm on Next.js (web), Hono (server).

However, this was only happening on iOS (mobile, on both Chrome and Safari) as well as mac (only Safari, not Chrome)

I know the localhost calls won't work on Safari due to some security requirement for https
When I inspected the network calls (for my staging, hosted domain and local), the error I kept seeing was:

User-Agent is not allowed by Access-Control-Allow-Headers.

So I added it to my cors and it worked (on deployed versions, not on localhost Safari)

   allowHeaders: [
     "Content-Type",
     "Authorization",
+    "User-Agent",
   ]

I suspect this got removed at some point by either of the packages or plugins? Or I'm missing something.
Now my web, server, native all work - except over localhost on Safari.

For reference:

Relevant part of the better-auth config:

 advanced: {
    crossSubDomainCookies: {
      enabled: env.NODE_ENV !== "development",
      domain: env.CROSS_SUB_DOMAIN_COOKIES_DOMAIN,
    },
    cookiePrefix: env.BETTER_AUTH_COOKIE_PREFIX, // To provide specificity for which cookie to read from
    defaultCookieAttributes: {
      secure: true,
      httpOnly: true,
      sameSite: "none",
      partitioned: true,
    },
  },

To make it work with my Expo (native app), I had to add the below for local development

account: {
    // Skip state cookie check in development for Expo compatibility
    // The expo plugin uses SecureStore for state management, but cookies may still be checked
    skipStateCookieCheck: env.NODE_ENV === "development",
}

And had to the below snippet from Hexi (source)

app.use(async (c, next) => {
    const ExpoOrigin = c.req.header('expo-origin')
    if (ExpoOrigin) {
        c.req.raw.headers.set('origin', ExpoOrigin)
        await next()
    }
})
<!-- gh-comment-id:3584861929 --> @banuragaxioned commented on GitHub (Nov 27, 2025): In case it helps anyone else .. I was having a similar issue (kept seeing 204 OPTIONS) after I upgraded to 1.4.x (and to Next 16 at the same time). This wasn't the case before the upgrades. I'm on Next.js (web), Hono (server). However, this was only happening on iOS (mobile, on both Chrome and Safari) as well as mac (only Safari, not Chrome) I know the localhost calls won't work on Safari due to some security requirement for https When I inspected the network calls (for my staging, hosted domain and local), the error I kept seeing was: > User-Agent is not allowed by Access-Control-Allow-Headers. So I added it to my cors and it worked (on deployed versions, not on localhost Safari) ```diff allowHeaders: [ "Content-Type", "Authorization", + "User-Agent", ] ``` I suspect this got removed at some point by either of the packages or plugins? Or I'm missing something. **Now my web, server, native all work** _- except over localhost on Safari_. **For reference:** Relevant part of the better-auth config: ```ts advanced: { crossSubDomainCookies: { enabled: env.NODE_ENV !== "development", domain: env.CROSS_SUB_DOMAIN_COOKIES_DOMAIN, }, cookiePrefix: env.BETTER_AUTH_COOKIE_PREFIX, // To provide specificity for which cookie to read from defaultCookieAttributes: { secure: true, httpOnly: true, sameSite: "none", partitioned: true, }, }, ``` To make it work with my Expo (native app), I had to add the below for local development ```ts account: { // Skip state cookie check in development for Expo compatibility // The expo plugin uses SecureStore for state management, but cookies may still be checked skipStateCookieCheck: env.NODE_ENV === "development", } ``` And had to the below snippet from Hexi ([source](https://www.answeroverflow.com/m/1434148072409268274)) ```ts app.use(async (c, next) => { const ExpoOrigin = c.req.header('expo-origin') if (ExpoOrigin) { c.req.raw.headers.set('origin', ExpoOrigin) await next() } }) ```
Author
Owner

@dosubot[bot] commented on GitHub (Feb 26, 2026):

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

Issue Summary:

  • You reported a CORS problem with Next.js App Router and Better Auth where preflight OPTIONS requests lacked necessary CORS headers despite correct trustedOrigins setup.
  • Better Auth does not automatically set CORS headers for Next.js handlers; explicit CORS middleware is needed to handle OPTIONS requests properly.
  • Versions ≥1.3.11 of Better Auth support automatic cross-origin cookies via the advanced.crossOriginCookies config, requiring HTTPS and proper cookie attributes (SameSite=None, Secure).
  • Users experienced login failures after enabling crossOriginCookies; a detailed troubleshooting checklist and user-shared workarounds were provided.
  • The issue was resolved by adding explicit CORS middleware and following the recommended configuration and troubleshooting steps.

Next Steps:

  • Please confirm if this issue is still relevant with the latest version of better-auth; if so, you can keep the discussion open by commenting here.
  • Otherwise, I will automatically close this issue in 7 days.

Thanks for your understanding and contribution!

<!-- gh-comment-id:3967622458 --> @dosubot[bot] commented on GitHub (Feb 26, 2026): Hi, @amirhd-com. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You reported a CORS problem with Next.js App Router and Better Auth where preflight OPTIONS requests lacked necessary CORS headers despite correct trustedOrigins setup. - Better Auth does not automatically set CORS headers for Next.js handlers; explicit CORS middleware is needed to handle OPTIONS requests properly. - Versions ≥1.3.11 of Better Auth support automatic cross-origin cookies via the advanced.crossOriginCookies config, requiring HTTPS and proper cookie attributes (SameSite=None, Secure). - Users experienced login failures after enabling crossOriginCookies; a detailed troubleshooting checklist and user-shared workarounds were provided. - The issue was resolved by adding explicit CORS middleware and following the recommended configuration and troubleshooting steps. **Next Steps:** - Please confirm if this issue is still relevant with the latest version of better-auth; if so, you can keep the discussion open by commenting here. - Otherwise, I will automatically close this issue in 7 days. Thanks for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#18665