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
- Retry the SSO flow entirely in the same browser/client that started it, without switching devices or browsers mid-flow
- Check any proxy/CDN/WAF config so it forwards the client's User-Agent unchanged (or consistently) on both the initiation and callback endpoints
- If user-agent binding is too strict for your environment, set passbolt.security.userAgent=false in config/passbolt.php and redeploy
- Inspect the sso_states table row (user_agent column) vs the incoming request header to identify what changed
- 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
- Complete the SSO flow in the same browser/device that started it
- Keep proxy/CDN User-Agent handling consistent between initiation and callback endpoints
- Only disable passbolt.security.userAgent deliberately, with the trade-off documented
- Monitor for repeated mismatches that may indicate a header-rewriting intermediary
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
- The SSO state is invalid. Settings mismatch.
- Single sign-on failed. The provider address is not allowed.
- The SSO authentication token is invalid. User agent is…
- The SSO authentication token is invalid. User IP mismatch.
- The SSO state is invalid. User id mismatch.
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)