immich-app/immich · error · BadRequestException

OAuth authentication failed

Error message

OAuth authentication failed

What it means

Thrown by callback() during account linking: a user exists with the profile's email but that user is already linked to a different oauthId, so the server refuses to link and fails authentication, preventing account takeover via email collision.

Solutions

  1. Unlink the existing OAuth account (admin user settings) or clear its oauthId, then log in again to relink
  2. Delete or merge the stale duplicate account
  3. Ensure the provider returns a stable sub claim
  4. Verify the intended provider/issuer is configured

Example fix

// before
// alice@x.com has oauthId 'old-sub', logs in via 'new-sub' -> 400
// after
// admin: unlink OAuth from alice's account, then retry login to relink with 'new-sub'
Defensive patterns

Strategy: try-catch

Try / catch

try { await api.oauthCallback(dto, headers) } catch (e) { if (e.status === 400 && /OAuth authentication failed/.test(e.message)) { /* check for account already linked to another oauthId */ } throw e; }

Prevention

When it happens

Trigger: OAuth profile's email matches an Immich user whose oauthId differs from profile.sub; user previously linked to another provider/subject.

Common situations: Switching OAuth providers (same emails, new subject IDs); duplicate accounts sharing an email; provider re-issuing sub values.

Understand the failure class

Related errors


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

Appendix: source

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

    const url = this.resolveRedirectUri(oauth, dto.url);
    const {
      profile,
      sid: oauthSid,
      idToken: oauthBearerToken,
    } = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
    const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
    const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
    this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
    let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);

    // link by email
    if (!user && normalizedEmail) {
      const emailUser = await this.userRepository.getByEmail(normalizedEmail);
      if (emailUser) {
        if (emailUser.oauthId) {
          this.logger.debug('OAuth login conflict: email already linked to different account');
          throw new BadRequestException('OAuth authentication failed');
        }
        user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
      }
    }

    const role = this.getRoleClaim(profile, roleClaim);
    const isAdmin = role === 'admin';

    if (user && role && isAdmin !== user.isAdmin) {
      user = await this.userRepository.update(user.id, { isAdmin });
    }

    // register new user
    if (!user) {
      if (!autoRegister) {
        this.logger.warn(
          `Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`,
        );

View on GitHub (pinned to f48d4b3321)