immich-app/immich · error

OAuth link account failed: sub is already linked to another…

Error message

OAuth link account failed: sub is already linked to another user (${duplicate.email}).

What it means

When linking an OAuth identity to an already-logged-in account (POST /oauth/link), the server checks whether the OAuth subject (sub) is already attached to a different user. If so, it logs this warning naming the duplicate user's email and throws BadRequestException('This OAuth account has already been linked to another user.').

Solutions

  1. Unlink the OAuth identity from the other account first (that account's OAuth settings page).
  2. Use a distinct identity (different IdP account/email) for each server user.
  3. If the other account is stale, delete it or remove its OAuth binding via admin tools.
  4. Verify the IdP isn't returning the same sub for different end users.
Defensive patterns

Strategy: try-catch

Validate before calling

// client-side pre-check before linking
const linked = await api.getMyOAuthAccount();
if (linked) throw new Error('This identity is already linked to another account');

Try / catch

try {
  await api.oauthLink(url);
} catch (e) {
  if (e instanceof BadRequestException && e.message.includes('already been linked')) {
    showToast('This OAuth account is bound to a different user; unlink it there first');
  } else throw e;
}

Prevention

When it happens

Trigger: oauth/link called with dto.url for an OAuth identity whose sub resolves (getByOAuthId) to a user with a different id than auth.user.id.

Common situations: Two server accounts both trying to bind the same Google/OIDC identity; reusing a test OAuth account; IdP reusing a sub across accounts; family members sharing one provider login.

Understand the failure class

Background: Conflicting config options: "cannot be used together" — configuration validation errors across open-source libraries — this error's family across 162 libraries.

Related errors


AI-assisted analysis of immich-app/immich@f48d4b3321 (2026-09-15). Data as JSON: /api/errors/1c6a4a3819c6b662. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/auth.service.ts:424

    const expectedState = dto.state ?? this.getCookieOauthState(headers);
    if (!expectedState?.length) {
      throw new BadRequestException('OAuth state is missing');
    }

    const codeVerifier = dto.codeVerifier ?? this.getCookieCodeVerifier(headers);
    if (!codeVerifier?.length) {
      throw new BadRequestException('OAuth code verifier is missing');
    }

    const { oauth } = await this.getConfig({ withCache: false });
    const {
      profile: { sub: oauthId },
      sid,
      idToken,
    } = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
    const duplicate = await this.userRepository.getByOAuthId(oauthId);
    if (duplicate && duplicate.id !== auth.user.id) {
      this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
      throw new BadRequestException('This OAuth account has already been linked to another user.');
    }

    if (auth.session && (sid || idToken)) {
      await this.sessionRepository.update(auth.session.id, {
        oauthSid: sid,
        oauthBearerToken: idToken,
      });
    }

    const user = await this.userRepository.update(auth.user.id, { oauthId });
    return mapUserAdmin(user);
  }

  async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
    if (auth.session) {
      await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null });
    }

View on GitHub (pinned to f48d4b3321)