Fastify + Next.js: CORS Issue #1712

Closed
opened 2026-03-13 08:57:56 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @felipemotarocha on GitHub (Aug 15, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create a Fastify back-end
  2. Deploy the back-end to ECS with ECR
  3. Call the back-end in a Next.js app

Current vs. Expected behavior

After following the steps of the previous section, I get a CORS error:

login:1 Access to fetch at 'https://preproduction-api.numetryc.com.br/api/auth/sign-in/social' from origin 'https://preproduction-app.numetryc.com.br' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Endpoints that I manually created on my API are working fine, and no CORS errors are happening. CORS errors are only happening on Better Auth endpoints.

What version of Better Auth are you using?

1.3.6

System info

System:
    OS: macOS 15.6
    CPU: (10) arm64 Apple M2 Pro
    Memory: 165.98 MB / 16.00 GB
    Shell: 5.9 - /bin/zsh
  Browsers:
    Chrome: 139.0.7258.128
    Safari: 18.6

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

Backend, Client

Auth config (if applicable)

// Fastify API
import { stripe } from "@better-auth/stripe";
import { and, db, eq } from "@repo/db";
import * as schema from "@repo/db";
import { ac, admin, member, owner } from "@repo/permissions";
import { StoreInvitationEmail } from "@repo/transactional";
import { betterAuth, BetterAuthOptions } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { customSession, magicLink, organization } from "better-auth/plugins";
import { Resend } from "resend";
import Stripe from "stripe";

import { env } from "../config/env";
import { resend } from "../email/resend";

const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
  // @ts-expect-error - TODO: fix this
  apiVersion: "2025-02-24.acacia",
});

const authOptions = {
  secret: env.AUTH_SECRET,
  baseURL: env.API_URL,
  trustedOrigins: [env.WEB_APP_URL, env.API_URL],
  user: {
    modelName: "usersTable",
  },
  session: {
    modelName: "sessionsTable",
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // Cache duration in seconds
    },
  },
  account: {
    modelName: "accountsTable",
  },
  verification: {
    modelName: "verificationsTable",
  },
  database: drizzleAdapter(db, {
    provider: "pg",
    schema,
  }),
  socialProviders: {
    google: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
  },
  advanced: {
    crossSubDomainCookies: {
      enabled: true,
    },
    database: {
      generateId: false,
    },
  },
} satisfies BetterAuthOptions;

export const auth = betterAuth({
  ...authOptions,
  plugins: [
    stripe({
      stripeClient,
      stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET,
      createCustomerOnSignUp: true,
      subscription: {
        enabled: true,
        plans: [
          {
            name: "founder",
            priceId: env.STRIPE_FOUNDER_PLAN_MONTHLY_PRICE_ID,
            annualDiscountPriceId: env.STRIPE_FOUNDER_PLAN_YEARLY_PRICE_ID,
          },
        ],
        authorizeReference: async ({ user, referenceId }) => {
          const member = await db.query.usersToStoresTable.findFirst({
            where: and(
              eq(schema.usersToStoresTable.userId, user.id),
              eq(schema.usersToStoresTable.storeId, referenceId),
            ),
          });
          return member?.role === "owner";
        },
      },
      schema: {
        subscription: {
          modelName: "subscriptionsTable",
        },
      },
    }),
    organization({
      ac,
      roles: {
        owner,
        admin,
        member,
      },
      sendInvitationEmail: async ({
        email,
        inviter,
        organization,
        invitation,
      }) => {
        try {
          await resend.emails.send({
            from: "NUMETRYC <no-reply@numetryc.com.br>",
            to: email,
            subject: "Convite para se juntar à loja",
            react: (
              <StoreInvitationEmail
                invitedByUsername={inviter.user.name}
                invitedByEmail={inviter.user.email}
                storeName={organization.name}
                invitationLink={`${env.WEB_APP_URL}/accept-invitation/${invitation.id}`}
              />
            ),
          });
          console.log("Email sent");
        } catch (error) {
          console.error(error);
        }
      },
      schema: {
        organization: {
          modelName: "storesTable",
        },
        member: {
          modelName: "usersToStoresTable",
          fields: {
            organizationId: "storeId",
          },
        },
        invitation: {
          modelName: "invitationsTable",
          fields: {
            organizationId: "storeId",
          },
        },
      },
    }),
    customSession(async ({ user, session }) => {
      const stores = await db.query.usersToStoresTable.findMany({
        where: eq(schema.usersToStoresTable.userId, user.id),
        columns: { storeId: true },
      });
      return {
        user: {
          ...user,
          stores: stores.map(store => store.storeId),
        },
        session,
      };
    }, authOptions),
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        const resend = new Resend(env.RESEND_API_KEY);
        await resend.emails.send({
          from: "Numetryc <no-reply@numetryc.com.br>",
          to: email,
          subject: "Faça login na Numetryc",
          html: `<p>Clique <a href="${url}">aqui</a> para fazer login</p>`,
        });
      },
    }),
  ],
});

// auth-client.tsx (Next.js app)
import { stripeClient } from "@better-auth/stripe/client";
import { ac, admin, member, owner } from "@repo/permissions";
import {
  inferAdditionalFields,
  magicLinkClient,
  organizationClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";

import { env } from "./env";

export const authClient = createAuthClient({
  baseURL: env.NEXT_PUBLIC_API_URL,
  plugins: [
    organizationClient({
      ac,
      roles: {
        owner,
        admin,
        member,
      },
    }),
    inferAdditionalFields({
      user: {
        stores: {
          type: "string[]",
        },
      },
    }),
    stripeClient({
      subscription: true,
    }),
    magicLinkClient(),
  ],
});

export type Session = typeof authClient.$Infer.Session;

Additional context

  • This issue is not happening when running the application locally. It's only happening on the deployed version of the app.
  • I tried to disable CORS completely in my Fastify API, but it didn't solve it.
  • I tried to add a fetchOptions.mode: 'no-cors' to the authClient, but it didn't solve it.
    -I tried to add a fetchOptions.credentials: 'include' to the authClient, but it didn't solve it.

This is my Fastify's API CORS configuration:

await app.register(fastifyCors, {
  origin: [env.WEB_APP_URL],
  methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
  credentials: true,
  maxAge: 86400,
});
Originally created by @felipemotarocha on GitHub (Aug 15, 2025). ### Is this suited for github? - [ ] Yes, this is suited for github ### To Reproduce 1. Create a Fastify back-end 2. Deploy the back-end to ECS with ECR 3. Call the back-end in a Next.js app ### Current vs. Expected behavior After following the steps of the previous section, I get a CORS error: `login:1 Access to fetch at 'https://preproduction-api.numetryc.com.br/api/auth/sign-in/social' from origin 'https://preproduction-app.numetryc.com.br' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.` Endpoints that I manually created on my API are working fine, and no CORS errors are happening. CORS errors are only happening on Better Auth endpoints. ### What version of Better Auth are you using? 1.3.6 ### System info ```bash System: OS: macOS 15.6 CPU: (10) arm64 Apple M2 Pro Memory: 165.98 MB / 16.00 GB Shell: 5.9 - /bin/zsh Browsers: Chrome: 139.0.7258.128 Safari: 18.6 ``` ### Which area(s) are affected? (Select all that apply) Backend, Client ### Auth config (if applicable) ```typescript // Fastify API import { stripe } from "@better-auth/stripe"; import { and, db, eq } from "@repo/db"; import * as schema from "@repo/db"; import { ac, admin, member, owner } from "@repo/permissions"; import { StoreInvitationEmail } from "@repo/transactional"; import { betterAuth, BetterAuthOptions } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { customSession, magicLink, organization } from "better-auth/plugins"; import { Resend } from "resend"; import Stripe from "stripe"; import { env } from "../config/env"; import { resend } from "../email/resend"; const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, { // @ts-expect-error - TODO: fix this apiVersion: "2025-02-24.acacia", }); const authOptions = { secret: env.AUTH_SECRET, baseURL: env.API_URL, trustedOrigins: [env.WEB_APP_URL, env.API_URL], user: { modelName: "usersTable", }, session: { modelName: "sessionsTable", cookieCache: { enabled: true, maxAge: 5 * 60, // Cache duration in seconds }, }, account: { modelName: "accountsTable", }, verification: { modelName: "verificationsTable", }, database: drizzleAdapter(db, { provider: "pg", schema, }), socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }, }, advanced: { crossSubDomainCookies: { enabled: true, }, database: { generateId: false, }, }, } satisfies BetterAuthOptions; export const auth = betterAuth({ ...authOptions, plugins: [ stripe({ stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "founder", priceId: env.STRIPE_FOUNDER_PLAN_MONTHLY_PRICE_ID, annualDiscountPriceId: env.STRIPE_FOUNDER_PLAN_YEARLY_PRICE_ID, }, ], authorizeReference: async ({ user, referenceId }) => { const member = await db.query.usersToStoresTable.findFirst({ where: and( eq(schema.usersToStoresTable.userId, user.id), eq(schema.usersToStoresTable.storeId, referenceId), ), }); return member?.role === "owner"; }, }, schema: { subscription: { modelName: "subscriptionsTable", }, }, }), organization({ ac, roles: { owner, admin, member, }, sendInvitationEmail: async ({ email, inviter, organization, invitation, }) => { try { await resend.emails.send({ from: "NUMETRYC <no-reply@numetryc.com.br>", to: email, subject: "Convite para se juntar à loja", react: ( <StoreInvitationEmail invitedByUsername={inviter.user.name} invitedByEmail={inviter.user.email} storeName={organization.name} invitationLink={`${env.WEB_APP_URL}/accept-invitation/${invitation.id}`} /> ), }); console.log("Email sent"); } catch (error) { console.error(error); } }, schema: { organization: { modelName: "storesTable", }, member: { modelName: "usersToStoresTable", fields: { organizationId: "storeId", }, }, invitation: { modelName: "invitationsTable", fields: { organizationId: "storeId", }, }, }, }), customSession(async ({ user, session }) => { const stores = await db.query.usersToStoresTable.findMany({ where: eq(schema.usersToStoresTable.userId, user.id), columns: { storeId: true }, }); return { user: { ...user, stores: stores.map(store => store.storeId), }, session, }; }, authOptions), magicLink({ sendMagicLink: async ({ email, url }) => { const resend = new Resend(env.RESEND_API_KEY); await resend.emails.send({ from: "Numetryc <no-reply@numetryc.com.br>", to: email, subject: "Faça login na Numetryc", html: `<p>Clique <a href="${url}">aqui</a> para fazer login</p>`, }); }, }), ], }); // auth-client.tsx (Next.js app) import { stripeClient } from "@better-auth/stripe/client"; import { ac, admin, member, owner } from "@repo/permissions"; import { inferAdditionalFields, magicLinkClient, organizationClient, } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import { env } from "./env"; export const authClient = createAuthClient({ baseURL: env.NEXT_PUBLIC_API_URL, plugins: [ organizationClient({ ac, roles: { owner, admin, member, }, }), inferAdditionalFields({ user: { stores: { type: "string[]", }, }, }), stripeClient({ subscription: true, }), magicLinkClient(), ], }); export type Session = typeof authClient.$Infer.Session; ``` ### Additional context - This issue is not happening when running the application locally. It's only happening on the deployed version of the app. - I tried to disable CORS completely in my Fastify API, but it didn't solve it. - I tried to add a `fetchOptions.mode: 'no-cors'` to the `authClient`, but it didn't solve it. -I tried to add a `fetchOptions.credentials: 'include'` to the `authClient`, but it didn't solve it. This is my Fastify's API CORS configuration: ```ts await app.register(fastifyCors, { origin: [env.WEB_APP_URL], methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], credentials: true, maxAge: 86400, }); ````
GiteaMirror added the nextjsfastify labels 2026-03-13 08:57:56 -05:00
Author
Owner

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

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

This CORS issue is likely caused by a conflict between Fastify's CORS plugin and Better Auth's internal CORS handling. Better Auth validates the incoming Origin against your trustedOrigins config and may set or override CORS headers independently, which can result in the wildcard * header or mismatches when credentials are included in requests (source).

To resolve this:

  1. Make sure your trustedOrigins array matches the browser's origin exactly (protocol, hostname, and port). Remove any unused hostnames.
  2. Ensure only one CORS handler is active for Better Auth endpoints. Try disabling Fastify's CORS plugin for /api/auth/* routes or configure Better Auth to handle CORS exclusively.
  3. Register your CORS middleware before your authentication routes—middleware order is critical for proper CORS handling (source).
  4. Double-check that your frontend requests use credentials: 'include' and your cookies are set with sameSite: 'none' and secure: true.

If the issue persists, please capture and share the full HTTP response headers from a failing Better Auth endpoint in production. This will help confirm which headers are being sent and pinpoint the source of the conflict.

To reply, just mention @dosu.


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

@dosubot[bot] commented on GitHub (Aug 15, 2025): <!-- Greeting --> Hi @felipemotarocha! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This CORS issue is likely caused by a conflict between Fastify's CORS plugin and Better Auth's internal CORS handling. Better Auth validates the incoming Origin against your trustedOrigins config and may set or override CORS headers independently, which can result in the wildcard `*` header or mismatches when credentials are included in requests ([source](https://github.com/better-auth/better-auth/issues/3874)). To resolve this: 1. Make sure your trustedOrigins array matches the browser's origin exactly (protocol, hostname, and port). Remove any unused hostnames. 2. Ensure only one CORS handler is active for Better Auth endpoints. Try disabling Fastify's CORS plugin for `/api/auth/*` routes or configure Better Auth to handle CORS exclusively. 3. Register your CORS middleware before your authentication routes—middleware order is critical for proper CORS handling ([source](https://github.com/better-auth/better-auth/pull/3707)). 4. Double-check that your frontend requests use `credentials: 'include'` and your cookies are set with `sameSite: 'none'` and `secure: true`. If the issue persists, please capture and share the full HTTP response headers from a failing Better Auth endpoint in production. This will help confirm which headers are being sent and pinpoint the source of the conflict. <!-- 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/20de74a1-39d3-4fb9-8569-91302161528f?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/20de74a1-39d3-4fb9-8569-91302161528f?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/20de74a1-39d3-4fb9-8569-91302161528f?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/20de74a1-39d3-4fb9-8569-91302161528f?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/20de74a1-39d3-4fb9-8569-91302161528f?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/20de74a1-39d3-4fb9-8569-91302161528f?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/20de74a1-39d3-4fb9-8569-91302161528f?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/4021)
Author
Owner

@felipemotarocha commented on GitHub (Aug 15, 2025):

The issue was in my API Gateway CORS configuration. I also set up CORS there, and now it's working well.

@felipemotarocha commented on GitHub (Aug 15, 2025): The issue was in my API Gateway CORS configuration. I also set up CORS there, and now it's working well.
Author
Owner

@rbalet commented on GitHub (Dec 1, 2025):

Note: Same issue with nest.js, remove cors: true from NestFactory.create()

@rbalet commented on GitHub (Dec 1, 2025): Note: Same issue with nest.js, remove `cors: true` from `NestFactory.create()`
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1712