{"record":{"id":"019bf7bd9ffae1e8","repo":"passbolt/passbolt_api","slug":"the-sso-state-is-invalid-user-agent-mismatch","errorCode":null,"errorMessage":"The SSO state is invalid. User agent mismatch.","messagePattern":"The SSO state is invalid\\. User agent mismatch\\.","errorType":"http","errorClass":"BadRequestException","httpStatus":400,"severity":"error","filePath":"plugins/PassboltEe/Sso/src/Service/SsoStates/SsoStatesAssertService.php","lineNumber":108,"sourceCode":"        }\n\n        if ($ssoState->isExpired()) {\n            throw new BadRequestException($errorMsg . __('The SSO state is expired.'));\n        }\n\n        if ($ssoState->user_id !== $uac->getId() || !Validation::uuid($ssoState->user_id)) {\n            throw new BadRequestException($errorMsg . __('User id mismatch.'));\n        }\n\n        if (Configure::read('passbolt.security.userIp')) {\n            if ($ssoState->ip !== $uac->getUserIp()) {\n                throw new BadRequestException($errorMsg . __('User IP mismatch.'));\n            }\n        }\n\n        if (Configure::read('passbolt.security.userAgent')) {\n            if ($ssoState->user_agent !== $uac->getUserAgent()) {\n                throw new BadRequestException($errorMsg . __('User agent mismatch.'));\n            }\n        }\n\n        if ($ssoState->sso_settings_id !== $ssoSettingsId || !Validation::uuid($ssoState->sso_settings_id)) {\n            throw new BadRequestException($errorMsg . __('Settings mismatch.'));\n        }\n    }\n\n    /**\n     * Same assertions but without user ID.\n     *\n     * @param \\Passbolt\\Sso\\Model\\Entity\\SsoState $ssoState SSO state entity.\n     * @param string $ssoSettingsId SSO Settings ID.\n     * @param \\App\\Utility\\ExtendedUserAccessControl $uac UAC object.\n     * @return void\n     */\n    private function assertWithoutUser(SsoState $ssoState, string $ssoSettingsId, ExtendedUserAccessControl $uac): void\n    {","sourceCodeStart":90,"sourceCodeEnd":126,"githubUrl":"https://github.com/passbolt/passbolt_api/blob/31c1bbc10f32808a607fa9bd81891e898779c0bc/plugins/PassboltEe/Sso/src/Service/SsoStates/SsoStatesAssertService.php#L90-L126","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// config/passbolt.php — before (strict user-agent binding)\n'security' => ['userIp' => true, 'userAgent' => true],\n// after (relax UA binding behind UA-rewriting proxies)\n'security' => ['userIp' => true, 'userAgent' => false],","handlingStrategy":"try-catch","validationCode":"// before completing the flow, ensure client binding is consistent\nif (Configure::read('passbolt.security.userAgent') && $ssoState->user_agent !== $uac->getUserAgent()) {\n    // restart the SSO flow instead of asserting\n    return $this->restartSsoFlow($uac);\n}","typeGuard":null,"tryCatchPattern":"try {\n    $this->ssoStatesAssertService->assertAndConsume($ssoState, $ssoSettingsId, $uac);\n} catch (BadRequestException $e) {\n    if (str_contains($e->getMessage(), 'User agent mismatch')) {\n        // surface a 'restart the login from the same browser' message\n    }\n    throw $e;\n}","preventionTips":["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"],"tags":["sso","csrf-state","user-agent","security","http-400"],"backgroundTag":"invalid-state-transition","analyzedSha":"31c1bbc10f32808a607fa9bd81891e898779c0bc","analyzedAt":"2026-09-17T00:04:38.960Z","contentChangedAt":"2026-09-17T00:04:38.960Z","schemaVersion":2},"datasetVersion":"2026-09-21T04:17:39.646Z"}