immich-app/immich · error · BadRequestException

OAuth state is missing

Error message

OAuth state is missing

What it means

Raised by AuthService.callback during the OAuth flow when no state parameter is available: dto.state is empty and the oauth state cookie (read via getCookieOauthState) is also absent. The state value is required to complete the authorization-code exchange safely, so the callback is rejected with a 400. Typically means the callback was opened in a different browser/session than the one that started the flow, cookies were blocked, or the state was omitted by the client.

Solutions

  1. Forward the state query param from the provider redirect into the callback call
  2. Verify cookies survive end-to-end (proxy forwards Cookie, correct domain)
  3. Complete the flow in one browser session so the oauth_state cookie persists
  4. Match the server external URL to the browser-facing URL

Example fix

// before
await api.callback({ url: redirectUrl });
// after
await api.callback({ url: redirectUrl, state: new URL(redirectUrl).searchParams.get('state') });
Defensive patterns

Strategy: validation

Validate before calling

const state = dto.state ?? getCookie('oauth_state'); if (!state) throw new Error('Missing OAuth state: forward the state param or keep cookies enabled');

Type guard

const hasState = (d: { state?: string }) => typeof d.state === 'string' && d.state.length > 0;

Try / catch

try { await api.oauthCallback(dto, headers) } catch (e) { if (e.status === 400 && /state is missing/.test(e.message)) { /* restart flow from authorize() */ } throw e; }

Prevention

When it happens

Trigger: callback() invoked with dto.state undefined and no oauth_state cookie in headers; cookies stripped or expired; state from a different domain.

Common situations: Proxy not passing Cookie headers; browser blocking cookies; manually calling the callback API without capturing state from the redirect; mismatched external URL breaking cookie domain.

Related errors


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

Appendix: source

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

    }

    return await this.oauthRepository.authorize(
      oauth,
      this.resolveRedirectUri(oauth, dto.redirectUri),
      dto.state,
      dto.codeChallenge,
    );
  }

  async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
    const { oauth } = await this.getConfig({ withCache: false });
    if (!oauth.enabled) {
      throw new BadRequestException('OAuth is not enabled');
    }

    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 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);

View on GitHub (pinned to f48d4b3321)