immich-app/immich · warning · BadRequestException

OAuth state is missing

Error message

OAuth state is missing

What it means

BadRequestException (HTTP 400) thrown by AuthService.callback when neither dto.state nor the immich_oauth_state cookie has a non-empty value. The state parameter is required to mitigate CSRF during the OAuth code exchange. Immich stores it in a cookie during authorize; on callback it reads dto.state first, then the cookie.

Source

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

    }

    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 199723261c)

Solutions

  1. Restart the flow with POST /oauth/authorize so a fresh state cookie is set, then complete the callback in the same browser session.
  2. Ensure cookies are sent with the callback request (credentials: 'include' on the fetch).
  3. Check the secure/sameSite attributes on immich_oauth_state against whether the site is HTTPS.
  4. Confirm the reverse proxy forwards the Cookie header to the callback handler.

Example fix

// before
await fetch('/oauth/callback', { method: 'POST', body: JSON.stringify({ url }) });
// cookies not sent

// after
await fetch('/oauth/authorize', { method: 'POST', body: JSON.stringify({ redirectUri }), credentials: 'include' });
// ...user authorizes on IdP...
await fetch('/oauth/callback', { method: 'POST', body: JSON.stringify({ url }), credentials: 'include' });
Defensive patterns

Strategy: validation

Validate before calling

function hasOauthState(dto: { state?: string }, cookies: Record<string, string>): boolean {
  return Boolean((dto.state && dto.state.length) || cookies.immich_oauth_state);
}

Type guard

function hasStateParam(dto: { state?: string }, cookie: string | null): dto is { state: string } {
  return Boolean(dto.state && dto.state.length) || Boolean(cookie);
}

Try / catch

try {
  await axios.post('/oauth/callback', { url }, { withCredentials: true });
} catch (e) {
  if (e.response?.data?.message === 'OAuth state is missing') {
    await restartOauthFlow();
  } else throw e;
}

Prevention

When it happens

Trigger: POST /oauth/callback with a body that omits `state`, sent by a client whose cookies do not contain immich_oauth_state. Common when the user cleared cookies mid-flow, used a different browser, or the callback URL was opened in a private window.

Common situations: User started OAuth in one browser tab and finished in another; cookies blocked by browser policy; the authorize response cookies were never set because of a same-site/secure mismatch; reverse proxy stripped cookies.

Related errors


AI-assisted analysis of immich-app/immich@199723261c (2026-08-12). Data as JSON: /api/errors/789903507ee579a7. Report an issue: GitHub.