[GH-ISSUE #8845] state_security_mismatch error on Google signin #28532

Open
opened 2026-04-17 19:59:02 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @SkepticMystic on GitHub (Mar 30, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/8845

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Current vs. Expected behavior

With this setup, the oauth flow should complete

What version of Better Auth are you using?

1.5.6

System info

The `auth` cli never works on my projects...
But some basic system info:
OS: Linux Mint 22.3 x86_64
Kernel: 6.8.0-106-generic
CPU: AMD Ryzen 5 3600 (12) @

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

Backend, Client

Auth config (if applicable)

import { getRequestEvent } from "$app/server";
import {
  BETTER_AUTH_SECRET,
  CAPTCHA_SECRET_KEY,
  GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET,
  PAYSTACK_SECRET_KEY,
} from "$env/static/private";
import { PUBLIC_BASE_URL } from "$env/static/public";
import { paystack, type PaystackPlan } from "@alexasomba/better-auth-paystack";
import { apiKey } from "@better-auth/api-key";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { passkey } from "@better-auth/passkey";
import { captureException } from "@sentry/sveltekit";
import { waitUntil } from "@vercel/functions";
import type { APIError } from "better-auth";
import { betterAuth } from "better-auth/minimal";
import {
  admin,
  captcha,
  haveIBeenPwned,
  lastLoginMethod,
  organization,
  twoFactor,
} from "better-auth/plugins";
import { sveltekitCookies } from "better-auth/svelte-kit";
import { APP } from "./const/app.const";
import { AccessControl } from "./const/auth/access_control.const";
import { AUTH, type IAuth } from "./const/auth/auth.const";
import { TWO_FACTOR } from "./const/auth/two_factor.const";
import { EMAIL } from "./const/email.const";
import { db } from "./server/db/drizzle.db";
import { type Session } from "./server/db/models/auth.model";
import { redis } from "./server/db/redis.db";
import { Repo } from "./server/db/repos/index.repo";
import { schema } from "./server/db/schema";
import { PaystackClient } from "./server/sdk/payment/paystack/paystack.payment.sdk";
import { AdapterService } from "./server/services/adapter/adapter.service";
import { Dicebear } from "./server/services/dicebear/dicebear.service";
import { DocService } from "./server/services/doc/doc.service";
import { EmailService } from "./server/services/email.service";
import { ImageService } from "./server/services/image/image.service";
import { OrderWebhookService } from "./server/services/order/order.webhook.service";
import { PayoutWebhookService } from "./server/services/payout/payout.webhook.service";
import { SubscriptionService } from "./server/services/subscription/subscription.service";
import { Log } from "./utils/logger.util";

// SECTION: betterAuth init
export const auth = betterAuth({
  appName: APP.NAME,
  baseURL: PUBLIC_BASE_URL,

  secrets: [
    // NOTE: New data is always encrypted with the latest key (first in the array), while decryption automatically tries all configured keys. This lets you roll secrets gradually without downtime or data loss.
    // {version: 2, value: BETTER_AUTH_SECRET},
    { version: 1, value: BETTER_AUTH_SECRET }, //
  ],

  logger: {
    level: "debug",
    log: (level, message, ...args) => {
      Log[level]({ args }, message);
    },
  },

  telemetry: {
    enabled: false,
  },

  experimental: {
    // TODO: Enable once BA support dirzzle 1.0
    joins: false,
  },

  advanced: {
    backgroundTasks: { handler: waitUntil },

    database: {
      // NOTE: Let drizzle generate IDs, as BetterAuth's nanoid causes issues
      // We want UUIDs everywhere, so that the image table can reference resource_id in a generic way
      generateId: false,
    },
  },

  database: drizzleAdapter(db, {
    schema,
    provider: "pg",
    debugLogs: false,
  }),

  session: {
    storeSessionInDatabase: false,
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // Cache duration in seconds
    },

    additionalFields: {
      // NOTE: These are set in the session create hook below
      org_id: {
        type: "string",
        input: false,
        returned: true,
        required: false,
        defaultValue: null,
      },
      member_id: {
        type: "string",
        input: false,
        returned: true,
        required: false,
        defaultValue: null,
      },
      member_role: {
        type: "string",
        input: false,
        returned: true,
        required: false,
        defaultValue: null,
      },
      active_plan: {
        type: "string",
        input: false,
        returned: true,
        required: false,
        defaultValue: null,
      },

      country: {
        type: "string",
        input: false,
        returned: true,
        required: false,
        defaultValue: null,
      },
    },
  },

  databaseHooks: {
    user: {
      create: {
        before: async (user) => {
          if (!user.image) {
            const image = Dicebear.get_url({ seed: user.email });
            if (image.ok) {
              user.image = image.data;
            }
          }

          return { data: user };
        },
      },
    },
    session: {
      create: {
        before: async (session) => {
          const geo = AdapterService.get_geo();

          const data = await get_active_org(session);

          return {
            data: {
              ...session,
              country: geo.country,

              org_id: data?.org_id,
              member_id: data?.member_id,
              member_role: data?.member_role,
              active_plan: data?.active_plan,
              activeOrganizationId: data?.org_id,
            },
          };
        },
      },
    },
  },

  user: {
    deleteUser: {
      enabled: true,
      sendDeleteAccountVerification: async ({ user, url }) => {
        await EmailService.send(
          EMAIL.TEMPLATES["delete-account-verification"]({ url, user }),
        );
      },

      beforeDelete: async (user) => {
        // TODO: If we allow users to upload images (e.g. avatar)
        // Then we also need to change the ImageTable to use user_id as a refernece, not just org_id
        // await ImageService.delete_many(
        //   {
        //     resource_id: user.id,
        //     resource_kind: "user",
        //   },
        //   session,
        // );

        await DocService.delete_many(
          {},
          {
            owner_id: user.id,
            owner_kind: "user",
          },
        );
      },
    },
  },

  verification: {
    storeIdentifier: "hashed",
    storeInDatabase: true,
  },

  account: {
    accountLinking: {
      enabled: true,
      updateUserInfoOnLink: true,
      // SOURCE: https://www.better-auth.com/docs/concepts/users-accounts#forced-linking
      // NOTE: Links profile even if email isn't verified on provider side
      trustedProviders: AUTH.PROVIDERS.IDS.filter(
        (id) => AUTH.PROVIDERS.MAP[id].force_email_verified,
      ),
    },
  },

  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    revokeSessionsOnPasswordReset: true,

    sendResetPassword: async ({ user, url }) => {
      await EmailService.send(EMAIL.TEMPLATES["password-reset"]({ url, user }));
    },
  },

  emailVerification: {
    sendOnSignUp: true,
    sendOnSignIn: true,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, url }) => {
      await EmailService.send(
        EMAIL.TEMPLATES["email-verification"]({ url, user }),
      );
    },
  },

  socialProviders: {
    google: {
      // Always prompt the user to select an account
      prompt: "select_account",
      clientId: GOOGLE_CLIENT_ID,
      clientSecret: GOOGLE_CLIENT_SECRET,
    },
  },

  plugins: [
    admin({
      ac: AccessControl.ac,
      roles: AccessControl.roles,
    }),

    captcha({
      secretKey: CAPTCHA_SECRET_KEY,
      provider: "cloudflare-turnstile",
    }),

    twoFactor({
      totpOptions: {
        digits: TWO_FACTOR.TOTP.DIGITS,
        period: TWO_FACTOR.TOTP.PERIOD_SECONDS,
      },
    }),

    passkey({
      rpName: APP.NAME,
      rpID: new URL(APP.URL).hostname,
    }),

    haveIBeenPwned({
      customPasswordCompromisedMessage:
        "That password has been compromised in a data breach. Please choose a different one.",
    }),

    lastLoginMethod({
      customResolveMethod: (ctx) => {
        // NOTE: The plugin uses different terminology to the rest of the lib...
        if (ctx.path === "/sign-in/email" || ctx.path === "/sign-up/email") {
          return "credential" satisfies IAuth.ProviderId;
        } else {
          // Return null to use default logic
          return null;
        }
      },
    }),

    organization({
      allowUserToCreateOrganization: false,
      cancelPendingInvitationsOnReInvite: true,
      requireEmailVerificationOnInvitation: true,

      sendInvitationEmail: async (data) => {
        await EmailService.send(EMAIL.TEMPLATES["org-invite"](data));
      },

      organizationHooks: {
        beforeDeleteOrganization: async ({ organization }) => {
          await Promise.all([
            ImageService.delete_many(
              {
                resource_id: organization.id,
                resource_kind: "organization",
              },
              { session: { org_id: organization.id } },
            ),
            DocService.delete_many(
              {},
              {
                owner_kind: "org",
                owner_id: organization.id,
              },
            ),
          ]);
        },
      },
    }),

    apiKey([
      {
        configId: "default",
        requireName: true,
        defaultPrefix: "sk_",
        references: "organization",

        // SOURCE: https://better-auth.com/docs/plugins/api-key/advanced#secondary-storage-with-fallback
        fallbackToDatabase: true,
        storage: "secondary-storage",
      },
    ]),

    paystack({
      paystackClient: PaystackClient,
      paystackWebhookSecret: PAYSTACK_SECRET_KEY,

      organization: {
        enabled: true,
        createCustomerOnOrganizationCreate: true,
      },

      subscription: {
        enabled: true,
        requireEmailVerification: true,

        plans: async () => {
          const plans = await PaystackClient.plan_list({});

          if (plans.error) {
            Log.error(plans.error, "auth.paystack.subscription.plans.error");

            return [];
          } else if (plans.data) {
            return plans.data.data
              .filter((p) => !p.is_archived && !p.is_deleted)
              .map((p) => ({
                name: p.name,
                amount: p.amount,
                currency: p.currency,
                planCode: p.plan_code,
                invoiceLimit: p.invoice_limit,
                // oxlint-disable-next-line typescript/no-unsafe-type-assertion
                interval: p.interval as PaystackPlan["interval"],
              }));
          } else {
            Log.error("auth.paystack.subscription.plans no data");
            return [];
          }
        },

        async authorizeReference(data) {
          const member = await Repo.query(
            db.query.member.findFirst({
              columns: { role: true },
              where: { userId: data.user.id, organizationId: data.referenceId },
            }),
          );

          if (!member.ok || !member.data || member.data.role !== "owner") {
            return false;
          }

          return true;
        },
      },

      onEvent: async (event) => {
        try {
          await Promise.all([
            PayoutWebhookService.handle_transfer_event(event),
            OrderWebhookService.handle_charge_event(event),
          ]);
        } catch (error) {
          Log.error(error, "auth.paystack.onEvent.error");
          captureException(error, { extra: { event_type: event.event } });
        }
      },
    }),

    // NOTE: Must be last, as it needs the request event
    // SOURCE: https://www.better-auth.com/docs/integrations/svelte-kit#server-action-cookies
    sveltekitCookies(getRequestEvent),
  ],

  // SOURCE: https://www.better-auth.com/docs/concepts/database#secondary-storage
  secondaryStorage: {
    get: async (key) => {
      return redis.get(APP.ID + ":" + key);
    },

    set: async (key, value, ttl) => {
      if (ttl) await redis.set(APP.ID + ":" + key, value, { ex: ttl });
      // or for ioredis:
      // if (ttl) await redis.set(key, value, "EX", ttl);
      else await redis.set(APP.ID + ":" + key, value);
    },

    delete: async (key) => {
      await redis.del(APP.ID + ":" + key);
    },
  },
});
// !SECTION

// SECTION: Helper functions
// NOTE: Renamed from get_or_create_org_id - no longer creates orgs automatically
// Organizations are now created via the onboarding flow after email verification
const get_active_org = async (
  session: Pick<Session, "userId">,
): Promise<{
  org_id: string;
  member_id: string;
  member_role: string;
  active_plan: string;
} | null> => {
  const log = Log.child({
    ctx: "[auth.session.create.before]",
    userId: session.userId,
  });

  try {
    const member = await Repo.query(
      db.query.member.findFirst({
        columns: { id: true, organizationId: true, role: true },
        where: { userId: session.userId },
        orderBy: { createdAt: "desc" },
      }),
    );

    if (!member.ok || !member.data) {
      log.debug("No organization found for user");
      return null;
    }

    log.debug(
      { organizationId: member.data.organizationId },
      "Found existing organization",
    );

    const subscription = await SubscriptionService.get_active({
      session: { org_id: member.data.organizationId },
    });

    if (!subscription.ok) {
      log.warn(
        { org_id: member.data.organizationId },
        "Subscription query failed during session creation — defaulting to free",
      );
    }

    const active_plan = subscription.ok
      ? (subscription.data?.plan ?? "free")
      : "free";

    return {
      active_plan,
      member_id: member.data.id,
      member_role: member.data.role,
      org_id: member.data.organizationId,
    };
  } catch (error) {
    log.error(error, "error unknown");
    captureException(error);
    return null;
  }
};

Additional context

Reproducible locally and in prod

Originally created by @SkepticMystic on GitHub (Mar 30, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/8845 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce - Follow the default Google setup: https://better-auth.com/docs/authentication/google - SvelteKit@^2.55.0 - Signin with Google: `authClient.signIn.social({ provider: "google" });` - On redirect back to app: server console shows `state_security_mismatch` ### Current vs. Expected behavior With this setup, the oauth flow should complete ### What version of Better Auth are you using? 1.5.6 ### System info ```bash The `auth` cli never works on my projects... But some basic system info: OS: Linux Mint 22.3 x86_64 Kernel: 6.8.0-106-generic CPU: AMD Ryzen 5 3600 (12) @ ``` ### Which area(s) are affected? (Select all that apply) Backend, Client ### Auth config (if applicable) ```typescript import { getRequestEvent } from "$app/server"; import { BETTER_AUTH_SECRET, CAPTCHA_SECRET_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, PAYSTACK_SECRET_KEY, } from "$env/static/private"; import { PUBLIC_BASE_URL } from "$env/static/public"; import { paystack, type PaystackPlan } from "@alexasomba/better-auth-paystack"; import { apiKey } from "@better-auth/api-key"; import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { passkey } from "@better-auth/passkey"; import { captureException } from "@sentry/sveltekit"; import { waitUntil } from "@vercel/functions"; import type { APIError } from "better-auth"; import { betterAuth } from "better-auth/minimal"; import { admin, captcha, haveIBeenPwned, lastLoginMethod, organization, twoFactor, } from "better-auth/plugins"; import { sveltekitCookies } from "better-auth/svelte-kit"; import { APP } from "./const/app.const"; import { AccessControl } from "./const/auth/access_control.const"; import { AUTH, type IAuth } from "./const/auth/auth.const"; import { TWO_FACTOR } from "./const/auth/two_factor.const"; import { EMAIL } from "./const/email.const"; import { db } from "./server/db/drizzle.db"; import { type Session } from "./server/db/models/auth.model"; import { redis } from "./server/db/redis.db"; import { Repo } from "./server/db/repos/index.repo"; import { schema } from "./server/db/schema"; import { PaystackClient } from "./server/sdk/payment/paystack/paystack.payment.sdk"; import { AdapterService } from "./server/services/adapter/adapter.service"; import { Dicebear } from "./server/services/dicebear/dicebear.service"; import { DocService } from "./server/services/doc/doc.service"; import { EmailService } from "./server/services/email.service"; import { ImageService } from "./server/services/image/image.service"; import { OrderWebhookService } from "./server/services/order/order.webhook.service"; import { PayoutWebhookService } from "./server/services/payout/payout.webhook.service"; import { SubscriptionService } from "./server/services/subscription/subscription.service"; import { Log } from "./utils/logger.util"; // SECTION: betterAuth init export const auth = betterAuth({ appName: APP.NAME, baseURL: PUBLIC_BASE_URL, secrets: [ // NOTE: New data is always encrypted with the latest key (first in the array), while decryption automatically tries all configured keys. This lets you roll secrets gradually without downtime or data loss. // {version: 2, value: BETTER_AUTH_SECRET}, { version: 1, value: BETTER_AUTH_SECRET }, // ], logger: { level: "debug", log: (level, message, ...args) => { Log[level]({ args }, message); }, }, telemetry: { enabled: false, }, experimental: { // TODO: Enable once BA support dirzzle 1.0 joins: false, }, advanced: { backgroundTasks: { handler: waitUntil }, database: { // NOTE: Let drizzle generate IDs, as BetterAuth's nanoid causes issues // We want UUIDs everywhere, so that the image table can reference resource_id in a generic way generateId: false, }, }, database: drizzleAdapter(db, { schema, provider: "pg", debugLogs: false, }), session: { storeSessionInDatabase: false, cookieCache: { enabled: true, maxAge: 5 * 60, // Cache duration in seconds }, additionalFields: { // NOTE: These are set in the session create hook below org_id: { type: "string", input: false, returned: true, required: false, defaultValue: null, }, member_id: { type: "string", input: false, returned: true, required: false, defaultValue: null, }, member_role: { type: "string", input: false, returned: true, required: false, defaultValue: null, }, active_plan: { type: "string", input: false, returned: true, required: false, defaultValue: null, }, country: { type: "string", input: false, returned: true, required: false, defaultValue: null, }, }, }, databaseHooks: { user: { create: { before: async (user) => { if (!user.image) { const image = Dicebear.get_url({ seed: user.email }); if (image.ok) { user.image = image.data; } } return { data: user }; }, }, }, session: { create: { before: async (session) => { const geo = AdapterService.get_geo(); const data = await get_active_org(session); return { data: { ...session, country: geo.country, org_id: data?.org_id, member_id: data?.member_id, member_role: data?.member_role, active_plan: data?.active_plan, activeOrganizationId: data?.org_id, }, }; }, }, }, }, user: { deleteUser: { enabled: true, sendDeleteAccountVerification: async ({ user, url }) => { await EmailService.send( EMAIL.TEMPLATES["delete-account-verification"]({ url, user }), ); }, beforeDelete: async (user) => { // TODO: If we allow users to upload images (e.g. avatar) // Then we also need to change the ImageTable to use user_id as a refernece, not just org_id // await ImageService.delete_many( // { // resource_id: user.id, // resource_kind: "user", // }, // session, // ); await DocService.delete_many( {}, { owner_id: user.id, owner_kind: "user", }, ); }, }, }, verification: { storeIdentifier: "hashed", storeInDatabase: true, }, account: { accountLinking: { enabled: true, updateUserInfoOnLink: true, // SOURCE: https://www.better-auth.com/docs/concepts/users-accounts#forced-linking // NOTE: Links profile even if email isn't verified on provider side trustedProviders: AUTH.PROVIDERS.IDS.filter( (id) => AUTH.PROVIDERS.MAP[id].force_email_verified, ), }, }, emailAndPassword: { enabled: true, requireEmailVerification: true, revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ user, url }) => { await EmailService.send(EMAIL.TEMPLATES["password-reset"]({ url, user })); }, }, emailVerification: { sendOnSignUp: true, sendOnSignIn: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { await EmailService.send( EMAIL.TEMPLATES["email-verification"]({ url, user }), ); }, }, socialProviders: { google: { // Always prompt the user to select an account prompt: "select_account", clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET, }, }, plugins: [ admin({ ac: AccessControl.ac, roles: AccessControl.roles, }), captcha({ secretKey: CAPTCHA_SECRET_KEY, provider: "cloudflare-turnstile", }), twoFactor({ totpOptions: { digits: TWO_FACTOR.TOTP.DIGITS, period: TWO_FACTOR.TOTP.PERIOD_SECONDS, }, }), passkey({ rpName: APP.NAME, rpID: new URL(APP.URL).hostname, }), haveIBeenPwned({ customPasswordCompromisedMessage: "That password has been compromised in a data breach. Please choose a different one.", }), lastLoginMethod({ customResolveMethod: (ctx) => { // NOTE: The plugin uses different terminology to the rest of the lib... if (ctx.path === "/sign-in/email" || ctx.path === "/sign-up/email") { return "credential" satisfies IAuth.ProviderId; } else { // Return null to use default logic return null; } }, }), organization({ allowUserToCreateOrganization: false, cancelPendingInvitationsOnReInvite: true, requireEmailVerificationOnInvitation: true, sendInvitationEmail: async (data) => { await EmailService.send(EMAIL.TEMPLATES["org-invite"](data)); }, organizationHooks: { beforeDeleteOrganization: async ({ organization }) => { await Promise.all([ ImageService.delete_many( { resource_id: organization.id, resource_kind: "organization", }, { session: { org_id: organization.id } }, ), DocService.delete_many( {}, { owner_kind: "org", owner_id: organization.id, }, ), ]); }, }, }), apiKey([ { configId: "default", requireName: true, defaultPrefix: "sk_", references: "organization", // SOURCE: https://better-auth.com/docs/plugins/api-key/advanced#secondary-storage-with-fallback fallbackToDatabase: true, storage: "secondary-storage", }, ]), paystack({ paystackClient: PaystackClient, paystackWebhookSecret: PAYSTACK_SECRET_KEY, organization: { enabled: true, createCustomerOnOrganizationCreate: true, }, subscription: { enabled: true, requireEmailVerification: true, plans: async () => { const plans = await PaystackClient.plan_list({}); if (plans.error) { Log.error(plans.error, "auth.paystack.subscription.plans.error"); return []; } else if (plans.data) { return plans.data.data .filter((p) => !p.is_archived && !p.is_deleted) .map((p) => ({ name: p.name, amount: p.amount, currency: p.currency, planCode: p.plan_code, invoiceLimit: p.invoice_limit, // oxlint-disable-next-line typescript/no-unsafe-type-assertion interval: p.interval as PaystackPlan["interval"], })); } else { Log.error("auth.paystack.subscription.plans no data"); return []; } }, async authorizeReference(data) { const member = await Repo.query( db.query.member.findFirst({ columns: { role: true }, where: { userId: data.user.id, organizationId: data.referenceId }, }), ); if (!member.ok || !member.data || member.data.role !== "owner") { return false; } return true; }, }, onEvent: async (event) => { try { await Promise.all([ PayoutWebhookService.handle_transfer_event(event), OrderWebhookService.handle_charge_event(event), ]); } catch (error) { Log.error(error, "auth.paystack.onEvent.error"); captureException(error, { extra: { event_type: event.event } }); } }, }), // NOTE: Must be last, as it needs the request event // SOURCE: https://www.better-auth.com/docs/integrations/svelte-kit#server-action-cookies sveltekitCookies(getRequestEvent), ], // SOURCE: https://www.better-auth.com/docs/concepts/database#secondary-storage secondaryStorage: { get: async (key) => { return redis.get(APP.ID + ":" + key); }, set: async (key, value, ttl) => { if (ttl) await redis.set(APP.ID + ":" + key, value, { ex: ttl }); // or for ioredis: // if (ttl) await redis.set(key, value, "EX", ttl); else await redis.set(APP.ID + ":" + key, value); }, delete: async (key) => { await redis.del(APP.ID + ":" + key); }, }, }); // !SECTION // SECTION: Helper functions // NOTE: Renamed from get_or_create_org_id - no longer creates orgs automatically // Organizations are now created via the onboarding flow after email verification const get_active_org = async ( session: Pick<Session, "userId">, ): Promise<{ org_id: string; member_id: string; member_role: string; active_plan: string; } | null> => { const log = Log.child({ ctx: "[auth.session.create.before]", userId: session.userId, }); try { const member = await Repo.query( db.query.member.findFirst({ columns: { id: true, organizationId: true, role: true }, where: { userId: session.userId }, orderBy: { createdAt: "desc" }, }), ); if (!member.ok || !member.data) { log.debug("No organization found for user"); return null; } log.debug( { organizationId: member.data.organizationId }, "Found existing organization", ); const subscription = await SubscriptionService.get_active({ session: { org_id: member.data.organizationId }, }); if (!subscription.ok) { log.warn( { org_id: member.data.organizationId }, "Subscription query failed during session creation — defaulting to free", ); } const active_plan = subscription.ok ? (subscription.data?.plan ?? "free") : "free"; return { active_plan, member_id: member.data.id, member_role: member.data.role, org_id: member.data.organizationId, }; } catch (error) { log.error(error, "error unknown"); captureException(error); return null; } }; ``` ### Additional context Reproducible locally and in prod
GiteaMirror added the platformoauthbug labels 2026-04-17 19:59:02 -05:00
Author
Owner

@SkepticMystic commented on GitHub (Apr 6, 2026):

The issue is auth.verification.storeIdentifier: "hashed". Commenting that out, or using plain storage fixes this oauth error.
I guess BA is comparing the hashed token to the unhashed state param
@ping-maxwell

<!-- gh-comment-id:4190822880 --> @SkepticMystic commented on GitHub (Apr 6, 2026): The issue is `auth.verification.storeIdentifier: "hashed"`. Commenting that out, or using plain storage fixes this oauth error. I guess BA is comparing the hashed token to the unhashed state param @ping-maxwell
Author
Owner

@ping-maxwell commented on GitHub (Apr 8, 2026):

Hey I was not able to reproduce this via unit tests.
The most likely culprit is a cookie not surviving the redirect round-trip (e.g., Secure cookie over HTTP, domain mismatch, or the sveltekitCookies plugin interfering with the signed cookie value encoding).

Are you able to make a minimal reproduction?

<!-- gh-comment-id:4203628321 --> @ping-maxwell commented on GitHub (Apr 8, 2026): Hey I was not able to reproduce this via unit tests. The most likely culprit is a cookie not surviving the redirect round-trip (e.g., Secure cookie over HTTP, domain mismatch, or the sveltekitCookies plugin interfering with the signed cookie value encoding). Are you able to make a minimal reproduction?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28532