immich-app/immich · error · BadRequestException

OAuth code verifier is missing

Error message

OAuth code verifier is missing

What it means

Guard in the OAuth callback flow: before exchanging the authorization code, the service requires a PKCE code verifier, taken from the request DTO or the oauth state cookie. If neither supplies one, the PKCE token exchange would fail server-side, so the request is rejected early with 400. Fires when a client completes the OAuth redirect without the verifier it generated at authorization start (lost/cleared cookies, stateless or misconfigured client).

Solutions

  1. Pass dto.codeVerifier explicitly, captured from the authorize() response
  2. Use the same HTTP client/session for authorize and callback so cookies persist
  3. Ensure the proxy forwards cookies
  4. Restart the full OAuth flow to get a fresh verifier

Example fix

// before
await api.callback({ url });
// after
const { codeVerifier } = await api.authorize(dto);
await api.callback({ url, codeVerifier });
Defensive patterns

Strategy: validation

Validate before calling

const verifier = dto.codeVerifier ?? getCookie('code_verifier'); if (!verifier) throw new Error('Missing PKCE code verifier; call authorize() first and keep its verifier');

Type guard

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

Try / catch

try { await api.oauthCallback(dto, headers) } catch (e) { if (e.status === 400 && /code verifier is missing/.test(e.message)) { /* redo authorize() then callback */ } throw e; }

Prevention

When it happens

Trigger: callback() called without codeVerifier in the DTO and without the verifier cookie; cookie lost between authorize and callback; separate HTTP clients for each leg.

Common situations: Headless scripts calling callback directly; cookie purged by redirect/proxy; HTTP clients that do not share cookie jars; PKCE mismatch after server upgrade.

Related errors


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

Appendix: source

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

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

    // link by email
    if (!user && normalizedEmail) {
      const emailUser = await this.userRepository.getByEmail(normalizedEmail);
      if (emailUser) {
        if (emailUser.oauthId) {

View on GitHub (pinned to f48d4b3321)