passbolt/passbolt_api · error · BadRequestException

The SSO state is invalid. User agent mismatch.

Error message

The SSO state is invalid. User agent mismatch.

What it means

During SSO sign-in, passbolt stores a state record binding the OAuth-like flow to the user's session context. The SsoStatesAssertService::assert() re-checks that the browser user agent completing the flow matches the one recorded when the state was created. When passbolt.security.userAgent is enabled and $ssoState->user_agent differs from $uac->getUserAgent(), a BadRequestException is thrown, the state is consumed (deleted), and the flow aborts.

Solutions

  1. Retry the SSO flow entirely in the same browser/client that started it, without switching devices or browsers mid-flow
  2. Check any proxy/CDN/WAF config so it forwards the client's User-Agent unchanged (or consistently) on both the initiation and callback endpoints
  3. If user-agent binding is too strict for your environment, set passbolt.security.userAgent=false in config/passbolt.php and redeploy
  4. Inspect the sso_states table row (user_agent column) vs the incoming request header to identify what changed
  5. Ensure browser extensions (privacy blockers) are not stripping the User-Agent header

Example fix

// config/passbolt.php — before (strict user-agent binding)
'security' => ['userIp' => true, 'userAgent' => true],
// after (relax UA binding behind UA-rewriting proxies)
'security' => ['userIp' => true, 'userAgent' => false],
Defensive patterns

Strategy: try-catch

Validate before calling

// before completing the flow, ensure client binding is consistent
if (Configure::read('passbolt.security.userAgent') && $ssoState->user_agent !== $uac->getUserAgent()) {
    // restart the SSO flow instead of asserting
    return $this->restartSsoFlow($uac);
}

Try / catch

try {
    $this->ssoStatesAssertService->assertAndConsume($ssoState, $ssoSettingsId, $uac);
} catch (BadRequestException $e) {
    if (str_contains($e->getMessage(), 'User agent mismatch')) {
        // surface a 'restart the login from the same browser' message
    }
    throw $e;
}

Prevention

When it happens

Trigger: assertAndConsume() -> assert() with passbolt.security.userAgent=true and the SSO state entity's user_agent differs from the user agent in the current ExtendedUserAccessControl. Happens when the SSO callback request arrives from a different client/browser than the one that initiated it.

Common situations: Reverse proxy or CDN rewriting/stripping the User-Agent header between initiation and callback; user restarting the flow in another browser or device; org-to-org SSO flows opened via email link on another machine; mobile app vs desktop browser; security header middleware (e.g. Anubis/proxies) replacing user agents; user-agent changes after browser upgrade mid-flow.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of passbolt/passbolt_api@31c1bbc10f (2026-09-17). Data as JSON: /api/errors/019bf7bd9ffae1e8. Report an issue: GitHub.

Appendix: source

Thrown at plugins/PassboltEe/Sso/src/Service/SsoStates/SsoStatesAssertService.php:108

        }

        if ($ssoState->isExpired()) {
            throw new BadRequestException($errorMsg . __('The SSO state is expired.'));
        }

        if ($ssoState->user_id !== $uac->getId() || !Validation::uuid($ssoState->user_id)) {
            throw new BadRequestException($errorMsg . __('User id mismatch.'));
        }

        if (Configure::read('passbolt.security.userIp')) {
            if ($ssoState->ip !== $uac->getUserIp()) {
                throw new BadRequestException($errorMsg . __('User IP mismatch.'));
            }
        }

        if (Configure::read('passbolt.security.userAgent')) {
            if ($ssoState->user_agent !== $uac->getUserAgent()) {
                throw new BadRequestException($errorMsg . __('User agent mismatch.'));
            }
        }

        if ($ssoState->sso_settings_id !== $ssoSettingsId || !Validation::uuid($ssoState->sso_settings_id)) {
            throw new BadRequestException($errorMsg . __('Settings mismatch.'));
        }
    }

    /**
     * Same assertions but without user ID.
     *
     * @param \Passbolt\Sso\Model\Entity\SsoState $ssoState SSO state entity.
     * @param string $ssoSettingsId SSO Settings ID.
     * @param \App\Utility\ExtendedUserAccessControl $uac UAC object.
     * @return void
     */
    private function assertWithoutUser(SsoState $ssoState, string $ssoSettingsId, ExtendedUserAccessControl $uac): void
    {

View on GitHub (pinned to 31c1bbc10f)