[GH-ISSUE #6481] SAML account linking requires trustedProviders but OIDC does not (inconsistent behavior) #10528

Open
opened 2026-04-13 06:43:58 -05:00 by GiteaMirror · 7 comments
Owner

Originally created by @joeyorlando on GitHub (Dec 3, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/6481

Originally assigned to: @Paola3stefania on GitHub.

Description

When a user logs in via SSO and an account with their email already exists in the database, Better Auth handles OIDC and SAML account linking differently. OIDC automatically links accounts based on email match, while SAML requires the provider to be in trustedProviders or have domainVerified: true.

Expected Behavior

Both OIDC and SAML should have the same account linking behavior. Either:

  1. Both should require trusted providers for automatic linking, OR
  2. Both should allow automatic linking based on email match (current OIDC behavior)

Current Behavior

OIDC Callback (~line 1719-1750 in @better-auth/sso/src/routes/sso.ts):

if (existingUser) {
  user = existingUser;
}
// ... later ...
if (!account) {
  // Just creates the account link - no trusted provider check!
  await ctx.context.internalAdapter.createAccount({...});
}

SAML Callback (~line 2076-2105 in @better-auth/sso/src/routes/sso.ts):

if (existingUser) {
  const account = await findAccount(...);
  if (!account) {
    // Requires trusted provider OR domain verification!
    const isTrustedProvider = trustedProviders?.includes(provider.providerId) ||
      ("domainVerified" in provider && provider.domainVerified && validateEmailDomain(...));
    
    if (!isTrustedProvider) {
      throw ctx.redirect(`${callbackUrl}?error=account_not_found`);
    }
    await ctx.context.internalAdapter.createAccount({...});
  }
}

Steps to Reproduce

  1. Create a user account with email user@example.com
  2. Configure a custom SAML provider (not in trustedProviders)
  3. Login via SAML with a user whose email is user@example.com
  4. Observe: Redirected with ?error=account_not_found

Compare with OIDC:

  1. Create a user account with email user@example.com
  2. Configure a custom OIDC provider (not in trustedProviders)
  3. Login via OIDC with a user whose email is user@example.com
  4. Observe: Account is linked successfully

Impact

  • Custom OIDC providers work fine for account linking
  • Custom SAML providers fail with account_not_found unless explicitly added to trustedProviders or domain verification is enabled and completed
  • This creates a confusing UX where OIDC "just works" but SAML requires extra configuration

Workarounds

  1. Add all custom provider IDs to trustedProviders (requires knowing them ahead of time)
  2. Enable domainVerification and complete the verification flow (complex)
  3. Don't use SAML with existing accounts (poor UX)

Environment

  • better-auth: 1.4.4-beta.3
  • @better-auth/sso: 1.4.4-beta.3

Suggested Fix

Apply the same account linking logic to both OIDC and SAML callbacks. If the security concern is that SAML assertions could be spoofed, the same concern applies to OIDC tokens, so both should be treated consistently.

Originally created by @joeyorlando on GitHub (Dec 3, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/6481 Originally assigned to: @Paola3stefania on GitHub. ## Description When a user logs in via SSO and an account with their email already exists in the database, Better Auth handles OIDC and SAML account linking differently. OIDC automatically links accounts based on email match, while SAML requires the provider to be in `trustedProviders` or have `domainVerified: true`. ## Expected Behavior Both OIDC and SAML should have the **same** account linking behavior. Either: 1. Both should require trusted providers for automatic linking, OR 2. Both should allow automatic linking based on email match (current OIDC behavior) ## Current Behavior **OIDC Callback** (~line 1719-1750 in `@better-auth/sso/src/routes/sso.ts`): ```typescript if (existingUser) { user = existingUser; } // ... later ... if (!account) { // Just creates the account link - no trusted provider check! await ctx.context.internalAdapter.createAccount({...}); } ``` **SAML Callback** (~line 2076-2105 in `@better-auth/sso/src/routes/sso.ts`): ```typescript if (existingUser) { const account = await findAccount(...); if (!account) { // Requires trusted provider OR domain verification! const isTrustedProvider = trustedProviders?.includes(provider.providerId) || ("domainVerified" in provider && provider.domainVerified && validateEmailDomain(...)); if (!isTrustedProvider) { throw ctx.redirect(`${callbackUrl}?error=account_not_found`); } await ctx.context.internalAdapter.createAccount({...}); } } ``` ## Steps to Reproduce 1. Create a user account with email `user@example.com` 2. Configure a custom SAML provider (not in `trustedProviders`) 3. Login via SAML with a user whose email is `user@example.com` 4. Observe: Redirected with `?error=account_not_found` Compare with OIDC: 1. Create a user account with email `user@example.com` 2. Configure a custom OIDC provider (not in `trustedProviders`) 3. Login via OIDC with a user whose email is `user@example.com` 4. Observe: Account is linked successfully ✅ ## Impact - Custom OIDC providers work fine for account linking - Custom SAML providers fail with `account_not_found` unless explicitly added to `trustedProviders` or domain verification is enabled and completed - This creates a confusing UX where OIDC "just works" but SAML requires extra configuration ## Workarounds 1. Add all custom provider IDs to `trustedProviders` (requires knowing them ahead of time) 2. Enable `domainVerification` and complete the verification flow (complex) 3. Don't use SAML with existing accounts (poor UX) ## Environment - `better-auth`: 1.4.4-beta.3 - `@better-auth/sso`: 1.4.4-beta.3 ## Suggested Fix Apply the same account linking logic to both OIDC and SAML callbacks. If the security concern is that SAML assertions could be spoofed, the same concern applies to OIDC tokens, so both should be treated consistently.
GiteaMirror added the securityenterprise labels 2026-04-13 06:43:58 -05:00
Author
Owner

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

You're correct—there's a confirmed inconsistency: OIDC account linking creates links based on email match without checking trustedProviders or domainVerified, while SAML requires the provider to be in trustedProviders or have domainVerified: true before linking. This difference is enforced only in the SAML callback logic and is not documented or justified in the code or docs—it's just how the SAML flow is currently implemented, and the workaround is to add provider IDs to trustedProviders or enable domain verification for SAML providers [source][source].

There’s no official rationale for this difference, and multiple users have reported the confusing UX. Unifying the logic would require either adding the trusted provider check to OIDC or removing it from SAML. If you want consistent behavior, you can safely align both flows by choosing one approach and updating the callback logic accordingly. Just be aware that relaxing the SAML restriction could have security implications if you don't fully trust all SAML assertions, but the same risk exists for OIDC if you accept any provider without verification [source].

If you want to proceed, the minimal code change is to remove the trustedProviders/domainVerified check from the SAML callback, or add it to OIDC for stricter control. No other blockers or dependencies were found.

To reply, just mention @dosu.


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

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3604486437 --> @dosubot[bot] commented on GitHub (Dec 3, 2025): <!-- Answer --> You're correct—there's a confirmed inconsistency: OIDC account linking creates links based on email match without checking `trustedProviders` or `domainVerified`, while SAML requires the provider to be in `trustedProviders` or have `domainVerified: true` before linking. This difference is enforced only in the SAML callback logic and is not documented or justified in the code or docs—it's just how the SAML flow is currently implemented, and the workaround is to add provider IDs to `trustedProviders` or enable domain verification for SAML providers [[source](https://github.com/better-auth/better-auth/blob/a29d6b51f2c6886d1b704b14c526918dc7a75c08/packages/sso/src/routes/sso.ts#L131-L2105)][[source](https://github.com/better-auth/better-auth/blob/a29d6b51f2c6886d1b704b14c526918dc7a75c08/docs/content/docs/errors/unable_to_link_account.mdx#L8-L28)]. There’s no official rationale for this difference, and multiple users have reported the confusing UX. Unifying the logic would require either adding the trusted provider check to OIDC or removing it from SAML. If you want consistent behavior, you can safely align both flows by choosing one approach and updating the callback logic accordingly. Just be aware that relaxing the SAML restriction could have security implications if you don't fully trust all SAML assertions, but the same risk exists for OIDC if you accept any provider without verification [[source](https://github.com/better-auth/better-auth/issues/5958)]. If you want to proceed, the minimal code change is to remove the trustedProviders/domainVerified check from the SAML callback, or add it to OIDC for stricter control. No other blockers or dependencies were found. <!-- 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/0a6c8c9c-2142-436a-9458-7559bee5b346?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/0a6c8c9c-2142-436a-9458-7559bee5b346?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/0a6c8c9c-2142-436a-9458-7559bee5b346?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/0a6c8c9c-2142-436a-9458-7559bee5b346?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/0a6c8c9c-2142-436a-9458-7559bee5b346?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/0a6c8c9c-2142-436a-9458-7559bee5b346?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/0a6c8c9c-2142-436a-9458-7559bee5b346?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%27re%20correct%E2%80%94there%27s%20a%20confirmed%20inconsistency%3A%20OIDC%20account%20linking%20creates%20links%20based%20on%20email%20match%20without%20checking%20%60trustedProviders%60%20or%20%60domainVerified%60%2C%20while%20SAML%20requires%20the%20provider%20to%20be%20in%20%60trustedProviders%60%20or%20have%20%60domainVerified%3A%20true%60%20before%20linking.%20This%20difference%20is%20enforced%20only%20in%20the%20SAML%20callback%20logic%20and%20is%20not%20documented%20or%20justified%20in%20the%20code%20or%20docs%E2%80%94it%27s%20just%20how%20the%20SAML%20flow%20is%20currently%20implemented%2C%20and%20the%20workaround%20is%20to%20add%20provider%20IDs%20to%20%60trustedProviders%60%20or%20enable%20domain%20verification%20for%20SAML%20providers%20%5B%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/blob/a29d6b51f2c6886d1b704b14c526918dc7a75c08/packages/sso/src/routes/sso.ts%23L131-L2105%29%5D%5B%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/blob/a29d6b51f2c6886d1b704b14c526918dc7a75c08/docs/content/docs/errors/unable_to_link_account.mdx%23L8-L28%29%5D.%0A%0AThere%E2%80%99s%20no%20official%20rationale%20for%20this%20difference%2C%20and%20multiple%20users%20have%20reported%20the%20confusing%20UX.%20Unifying%20the%20logic%20would%20require%20either%20adding%20the%20trusted%20provider%20check%20to%20OIDC%20or%20removing%20it%20from%20SAML.%20If%20you%20want%20consistent%20behavior%2C%20you%20can%20safely%20align%20both%20flows%20by%20choosing%20one%20approach%20and%20updating%20the%20callback%20logic%20accordingly.%20Just%20be%20aware%20that%20relaxing%20the%20SAML%20restriction%20could%20have%20security%20implications%20if%20you%20don%27t%20fully%20trust%20all%20SAML%20assertions%2C%20but%20the%20same%20risk%20exists%20for%20OIDC%20if%20you%20accept%20any%20provider%20without%20verification%20%5B%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/5958%29%5D.%0A%0AIf%20you%20want%20to%20proceed%2C%20the%20minimal%20code%20change%20is%20to%20remove%20the%20trustedProviders/domainVerified%20check%20from%20the%20SAML%20callback%2C%20or%20add%20it%20to%20OIDC%20for%20stricter%20control.%20No%20other%20blockers%20or%20dependencies%20were%20found.)&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/6481)
Author
Owner

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

I can work on this , @Bekacru , @ping-maxwell one question before i start -

  • Do you want me to remove the check from SAML (so it matches OIDC), or add the check to OIDC (making it stricter)?
    Removing the check from SAML seems better to avoid breaking changes for current OIDC users, but let me know what you prefer!
<!-- gh-comment-id:3605161230 --> @GautamBytes commented on GitHub (Dec 3, 2025): I can work on this , @Bekacru , @ping-maxwell one question before i start - - Do you want me to remove the check from SAML (so it matches OIDC), or add the check to OIDC (making it stricter)? Removing the check from SAML seems better to avoid breaking changes for current OIDC users, but let me know what you prefer!
Author
Owner

@Bekacru commented on GitHub (Dec 5, 2025):

OIDC automatically links accounts based on email match

have you enabled trustEmailVerified?

<!-- gh-comment-id:3618387478 --> @Bekacru commented on GitHub (Dec 5, 2025): > OIDC automatically links accounts based on email match have you enabled trustEmailVerified?
Author
Owner

@joeyorlando commented on GitHub (Dec 5, 2025):

have you enabled trustEmailVerified?

yes! my configuration is here

<!-- gh-comment-id:3618425693 --> @joeyorlando commented on GitHub (Dec 5, 2025): > have you enabled trustEmailVerified? yes! my configuration is [here](https://github.com/archestra-ai/archestra/blob/bd767d26102b91cb3829e8ca7cde4ce44d7504c0/platform/backend/src/auth/better-auth.ts#L131-L155)
Author
Owner

@Bekacru commented on GitHub (Dec 5, 2025):

I highly recommend not doing that as it basically opens your app for account takeover. See the warning in the docs regarding the option

<!-- gh-comment-id:3618541496 --> @Bekacru commented on GitHub (Dec 5, 2025): I highly recommend not doing that as it basically opens your app for account takeover. See the warning in the docs regarding the option
Author
Owner

@joeyorlando commented on GitHub (Dec 6, 2025):

I highly recommend not doing that as it basically opens your app for account takeover. See the warning in the docs regarding the option

I've seen the docs around this, but they're a bit light/vague imo -- for my case, for https://github.com/archestra-ai/archestra this is a self-hosted application where orgs configure their own SSO providers, so unless I misunderstand the implications of trustEmailVerified, I think it's fine wrt account takeover, no?

Also, I believe I set trustEmailVerified because I was running into some issues related to what I reported initially in this GH issue (and also as a workaround to 👇).

For our app the concept of trustedProviders was a bit clunky. I can understand if you're hosting a multi-tenant cloud SaaS but for Archestra, admins are deploying our application and then going through our UI to configure their SSO providers -- so trustedProviders aren't known at build-time. We could have admins specify an env var of provider IDs.. but this is an awful UX.

Maybe a "dynamic getter", getTrustedProviders would be a nice option here?

<!-- gh-comment-id:3620855211 --> @joeyorlando commented on GitHub (Dec 6, 2025): > I highly recommend not doing that as it basically opens your app for account takeover. See the warning in the docs regarding the option I've seen the docs around this, but they're a bit light/vague imo -- for my case, for https://github.com/archestra-ai/archestra this is a self-hosted application where orgs configure their own SSO providers, so unless I misunderstand the implications of `trustEmailVerified`, I _think_ it's fine wrt account takeover, no? Also, I believe I set `trustEmailVerified` because I was running into some issues related to what I reported initially in this GH issue (and also as a workaround to 👇). For our app the concept of `trustedProviders` was a bit clunky. I can understand if you're hosting a multi-tenant cloud SaaS but for Archestra, admins are deploying our application and then going through our UI to configure their SSO providers -- so `trustedProviders` aren't known at build-time. We _could_ have admins specify an env var of provider IDs.. but this is an awful UX. Maybe a "dynamic getter", `getTrustedProviders` would be a nice option here?
Author
Owner

@Siumauricio commented on GitHub (Feb 11, 2026):

I highly recommend not doing that as it basically opens your app for account takeover. See the warning in the docs regarding the option

I've seen the docs around this, but they're a bit light/vague imo -- for my case, for https://github.com/archestra-ai/archestra this is a self-hosted application where orgs configure their own SSO providers, so unless I misunderstand the implications of trustEmailVerified, I think it's fine wrt account takeover, no?

Also, I believe I set trustEmailVerified because I was running into some issues related to what I reported initially in this GH issue (and also as a workaround to 👇).

For our app the concept of trustedProviders was a bit clunky. I can understand if you're hosting a multi-tenant cloud SaaS but for Archestra, admins are deploying our application and then going through our UI to configure their SSO providers -- so trustedProviders aren't known at build-time. We could have admins specify an env var of provider IDs.. but this is an awful UX.

Maybe a "dynamic getter", getTrustedProviders would be a nice option here?

I created a PR to have dynamic getTrustedProviders, I have the same case where the users basically cannot update manually the providers

<!-- gh-comment-id:3882282277 --> @Siumauricio commented on GitHub (Feb 11, 2026): > > I highly recommend not doing that as it basically opens your app for account takeover. See the warning in the docs regarding the option > > I've seen the docs around this, but they're a bit light/vague imo -- for my case, for https://github.com/archestra-ai/archestra this is a self-hosted application where orgs configure their own SSO providers, so unless I misunderstand the implications of `trustEmailVerified`, I _think_ it's fine wrt account takeover, no? > > Also, I believe I set `trustEmailVerified` because I was running into some issues related to what I reported initially in this GH issue (and also as a workaround to 👇). > > For our app the concept of `trustedProviders` was a bit clunky. I can understand if you're hosting a multi-tenant cloud SaaS but for Archestra, admins are deploying our application and then going through our UI to configure their SSO providers -- so `trustedProviders` aren't known at build-time. We _could_ have admins specify an env var of provider IDs.. but this is an awful UX. > > Maybe a "dynamic getter", `getTrustedProviders` would be a nice option here? I created a PR to have dynamic getTrustedProviders, I have the same case where the users basically cannot update manually the providers
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10528