[GH-ISSUE #6863] [Stripe] Trial abuse: hasEverTrialed check uses wrong subscription set #10657

Closed
opened 2026-04-13 06:55:28 -05:00 by GiteaMirror · 9 comments
Owner

Originally created by @slig on GitHub (Dec 18, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/6863

Originally assigned to: @bytaesu on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

I've had issues where a user is able to get multiple trials, and this seems to be what's happening.

The PR #4562 fixed cross-plan trial abuse but I think it missed this: hasEverTrialed check can still return false for users who have trialed before.

https://github.com/better-auth/better-auth/blob/canary/packages/stripe/src/routes.ts#L202-L206

// findOne returns arbitrary subscription for referenceId
let subscriptionToUpdate = ...
    : referenceId
        ? await ctx.context.adapter.findOne<Subscription>({
            where: [{ field: "referenceId", value: referenceId }],
        })

//  subscriptions becomes just that ONE record
const subscriptions = subscriptionToUpdate
    ? [subscriptionToUpdate]
    : await ctx.context.adapter.findMany...

When user has multiple subscriptions (e.g. one canceled w/ trial history, one new incomplete), findOne returns whichever the DB returns first. If it's the new incomplete one, hasEverTrialed is false.

How to reproduce

  1. User subscribes with 7-day trial, completes checkout
  2. Subscription record has trialStart/trialEnd set
  3. User cancels -> status becomes "canceled"
  4. User starts new subscription -> new "incomplete" record created
  5. findOne returns the new incomplete subscription
  6. subscriptions = [subscriptionToUpdate] contains only that one
  7. hasEverTrialed returns false → user gets another trial

Fix The trial check should always query ALL subscriptions:

  // Always check ALL subscriptions for trial history, not just `subscriptionToUpdate`
  const allSubscriptionsForTrialCheck = await ctx.context.adapter.findMany<Subscription>({
      model: "subscription",
      where: [{ field: "referenceId", value: referenceId }],
  });

  const hasEverTrialed = allSubscriptionsForTrialCheck.some((s) => {
      const hadTrial = !!(s.trialStart || s.trialEnd) || s.status === "trialing";
      return hadTrial;
  });

This separates the "which subscription to update" logic from the "has user ever trialed" logic.

The workaround is to have something like this:

async function hasUserEverTrialed(userId: string): Promise<boolean> {
  const subscriptions = await db.query.subscription.findMany({
    where: eq(schema.subscription.referenceId, userId),
  });

  return subscriptions.some(
    (s) =>
      s.status === "trialing" ||
      s.status === "active" ||
      s.status === "canceled"
  );
}

And call it inside getCheckoutSessionParams:

        getCheckoutSessionParams: async ({ user }, request) => {
          // Workaround: check trial history across ALL subscriptions
          const hadTrial = await hasUserEverTrialed(user.id);
          const trialOverride = hadTrial
            ? { subscription_data: { trial_period_days: undefined } }
            : {};
...

Related: #4539, #4046, #4562

Current vs. Expected behavior

Users should not be able to trial abuse.

What version of Better Auth are you using?

1.4.7

System info

n/a

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

Backend

Auth config (if applicable)


Additional context

No response

Originally created by @slig on GitHub (Dec 18, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/6863 Originally assigned to: @bytaesu on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce I've had issues where a user is able to get multiple trials, and this seems to be what's happening. The PR #4562 fixed cross-plan trial abuse but I think it missed this: `hasEverTrialed` check can still return false for users who have trialed before. https://github.com/better-auth/better-auth/blob/canary/packages/stripe/src/routes.ts#L202-L206 ```ts // findOne returns arbitrary subscription for referenceId let subscriptionToUpdate = ... : referenceId ? await ctx.context.adapter.findOne<Subscription>({ where: [{ field: "referenceId", value: referenceId }], }) // subscriptions becomes just that ONE record const subscriptions = subscriptionToUpdate ? [subscriptionToUpdate] : await ctx.context.adapter.findMany... ```` When user has multiple subscriptions (e.g. one canceled w/ trial history, one new incomplete), `findOne` returns whichever the DB returns first. If it's the new incomplete one, `hasEverTrialed` is false. How to reproduce 1. User subscribes with 7-day trial, completes checkout 2. Subscription record has trialStart/trialEnd set 3. User cancels -> status becomes "canceled" 4. User starts new subscription -> new "incomplete" record created 5. `findOne` returns the new incomplete subscription 6. `subscriptions = [subscriptionToUpdate]` contains only that one 7. `hasEverTrialed` returns false → user gets another trial Fix The trial check should always query ALL subscriptions: ```ts // Always check ALL subscriptions for trial history, not just `subscriptionToUpdate` const allSubscriptionsForTrialCheck = await ctx.context.adapter.findMany<Subscription>({ model: "subscription", where: [{ field: "referenceId", value: referenceId }], }); const hasEverTrialed = allSubscriptionsForTrialCheck.some((s) => { const hadTrial = !!(s.trialStart || s.trialEnd) || s.status === "trialing"; return hadTrial; }); ``` This separates the "which subscription to update" logic from the "has user ever trialed" logic. The workaround is to have something like this: ``` async function hasUserEverTrialed(userId: string): Promise<boolean> { const subscriptions = await db.query.subscription.findMany({ where: eq(schema.subscription.referenceId, userId), }); return subscriptions.some( (s) => s.status === "trialing" || s.status === "active" || s.status === "canceled" ); } ``` And call it inside `getCheckoutSessionParams`: ``` getCheckoutSessionParams: async ({ user }, request) => { // Workaround: check trial history across ALL subscriptions const hadTrial = await hasUserEverTrialed(user.id); const trialOverride = hadTrial ? { subscription_data: { trial_period_days: undefined } } : {}; ... ``` Related: #4539, #4046, #4562 ### Current vs. Expected behavior Users should not be able to trial abuse. ### What version of Better Auth are you using? 1.4.7 ### System info ```bash n/a ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-13 06:55:28 -05:00
Author
Owner

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

Thanks for reporting this issue with the trial check logic. It appears that the current implementation relies on a single subscription fetched via a "findOne" query, which can lead to missing past trial data if a new, incomplete subscription is returned first. The proposed solution separates the logic by querying all subscriptions for the given reference ID and then checking if any of them indicate a trial history. This would prevent trial abuse by ensuring that even if one subscription doesn’t show a trial, any previous trial record prevents a new trial period from being granted [1].

To move forward, you could:
• Replace the "findOne" call for trial checking with a "findMany" call that checks all subscriptions for trial history.
• Implement the workaround as described, ensuring that the "hasUserEverTrialed" function queries the complete set of subscriptions.
• Verify in a staging environment that the new logic properly prevents users from exploiting trial periods.

Do you have any other questions about how to implement this change or further details on your subscription flow? Tag @better-auth-agent if you would like an answer from the Agent.

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

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3671081047 --> @better-auth-agent[bot] commented on GitHub (Dec 18, 2025): Thanks for reporting this issue with the trial check logic. It appears that the current implementation relies on a single subscription fetched via a "findOne" query, which can lead to missing past trial data if a new, incomplete subscription is returned first. The proposed solution separates the logic by querying all subscriptions for the given reference ID and then checking if any of them indicate a trial history. This would prevent trial abuse by ensuring that even if one subscription doesn’t show a trial, any previous trial record prevents a new trial period from being granted [[1]](https://github.com/better-auth/better-auth/issues/1183). To move forward, you could: • Replace the "findOne" call for trial checking with a "findMany" call that checks all subscriptions for trial history. • Implement the workaround as described, ensuring that the "hasUserEverTrialed" function queries the complete set of subscriptions. • Verify in a staging environment that the new logic properly prevents users from exploiting trial periods. Do you have any other questions about how to implement this change or further details on your subscription flow? Tag @better-auth-agent if you would like an answer from the Agent. _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@slig commented on GitHub (Dec 18, 2025):

@better-auth-agent can you fix this and propose a PR?

<!-- gh-comment-id:3671085378 --> @slig commented on GitHub (Dec 18, 2025): @better-auth-agent can you fix this and propose a PR?
Author
Owner

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

Hi there! Based on your description, it appears that our current trial logic is relying on a findOne query that may return an incomplete subscription record and thus bypass the trial history check. To address this, we recommend modifying the logic so that the trial check always queries all subscriptions for the given referenceId. Specifically, you’d replace the single-record retrieval with a findMany query and then determine whether any of the subscriptions have already been on trial by checking for trialStart, trialEnd, or a "trialing" status. This separates the “which subscription to update” logic (which can still use findOne for performance) from the “hasEverTrialed” logic, ensuring that users who have previously trialed can’t abuse the system [1].

A proposed PR (as you outlined) would have a snippet similar to:

  const allSubscriptionsForTrialCheck = await ctx.context.adapter.findMany({
    model: "subscription",
    where: [{ field: "referenceId", value: referenceId }],
  });
  const hasEverTrialed = allSubscriptionsForTrialCheck.some((s) => {
    const hadTrial = !!(s.trialStart || s.trialEnd) || s.status === "trialing";
    return hadTrial;
  });

This ensures that all subscriptions for that user are checked regardless of their status. An alternate workaround involves implementing a helper function that queries for all subscriptions and then checks for trial history before configuring the checkout session parameters accordingly [1].

Would you like more details on integrating these changes or further troubleshooting steps? Tag @better-auth-agent if you would like an answer from the Agent.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3671086773 --> @better-auth-agent[bot] commented on GitHub (Dec 18, 2025): Hi there! Based on your description, it appears that our current trial logic is relying on a findOne query that may return an incomplete subscription record and thus bypass the trial history check. To address this, we recommend modifying the logic so that the trial check always queries all subscriptions for the given referenceId. Specifically, you’d replace the single-record retrieval with a findMany query and then determine whether any of the subscriptions have already been on trial by checking for trialStart, trialEnd, or a "trialing" status. This separates the “which subscription to update” logic (which can still use findOne for performance) from the “hasEverTrialed” logic, ensuring that users who have previously trialed can’t abuse the system [[1]](https://github.com/better-auth/better-auth/issues/1183). A proposed PR (as you outlined) would have a snippet similar to:   const allSubscriptionsForTrialCheck = await ctx.context.adapter.findMany<Subscription>({     model: "subscription",     where: [{ field: "referenceId", value: referenceId }],   });   const hasEverTrialed = allSubscriptionsForTrialCheck.some((s) => {     const hadTrial = !!(s.trialStart || s.trialEnd) || s.status === "trialing";     return hadTrial;   }); This ensures that all subscriptions for that user are checked regardless of their status. An alternate workaround involves implementing a helper function that queries for all subscriptions and then checks for trial history before configuring the checkout session parameters accordingly [[1]](https://github.com/better-auth/better-auth/issues/1183). Would you like more details on integrating these changes or further troubleshooting steps? Tag @better-auth-agent if you would like an answer from the Agent. <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@GautamBytes commented on GitHub (Dec 23, 2025):

@bytaesu would you take a look at linked pr

<!-- gh-comment-id:3686101188 --> @GautamBytes commented on GitHub (Dec 23, 2025): @bytaesu would you take a look at linked pr
Author
Owner

@bytaesu commented on GitHub (Dec 23, 2025):

@bytaesu would you take a look at linked pr

Sure!

<!-- gh-comment-id:3686128644 --> @bytaesu commented on GitHub (Dec 23, 2025): > [@bytaesu](https://github.com/bytaesu) would you take a look at linked pr Sure!
Author
Owner

@slig commented on GitHub (Feb 10, 2026):

Hi,
I'm on 1.4.18 and I'm still getting trial abuse. People who cancel before the end of the trial, gets their status as canceled and somehow can still get another trial.

<!-- gh-comment-id:3879020482 --> @slig commented on GitHub (Feb 10, 2026): Hi, I'm on `1.4.18` and I'm still getting trial abuse. People who cancel before the end of the trial, gets their status as `canceled` and somehow can still get another trial.
Author
Owner

@bytaesu commented on GitHub (Feb 13, 2026):

Hi @slig,

I've made a fix since trial data might not be propagated properly through the webhook 🙏

<!-- gh-comment-id:3894622758 --> @bytaesu commented on GitHub (Feb 13, 2026): Hi @slig, I've made a fix since trial data might not be propagated properly through the webhook 🙏
Author
Owner

@slig commented on GitHub (Feb 13, 2026):

Thank you so much, @bytaesu !

<!-- gh-comment-id:3896832864 --> @slig commented on GitHub (Feb 13, 2026): Thank you so much, @bytaesu !
Author
Owner

@github-actions[bot] commented on GitHub (Apr 1, 2026):

This issue has been locked as it was closed more than 7 days ago. If you're experiencing a similar problem or you have additional context, please open a new issue and reference this one.

<!-- gh-comment-id:4166558493 --> @github-actions[bot] commented on GitHub (Apr 1, 2026): This issue has been locked as it was closed more than 7 days ago. If you're experiencing a similar problem or you have additional context, please open a new issue and reference this one.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10657