passbolt/passbolt_api · error · RecordNotFoundException

The SSO state does not exist.

Error message

The SSO state does not exist.

What it means

The state token is well-formed but no active (non-deleted, non-expired) SsoState row matches it in the sso_states table; firstOrFail() raises RecordNotFoundException which is re-thrown with this message and code 400. States are single-use: consume() marks them deleted immediately after assertion, so a used state also produces this error.

Solutions

  1. Restart the SSO login flow to generate a new state — a consumed or expired state can never be reused.
  2. If load-balanced, ensure all instances share the same database (or sticky sessions/consistent routing for the SSO round-trip).
  3. Do not refresh or replay the callback URL; each state is single-use by design.
  4. Check the sso_states table for the state value to confirm whether it was consumed (deleted set) or never created on that DB.
  5. Increase retention/cleanup window if states are being purged while users are still mid-flow (e.g. slow login pages, long manual approval steps).

Example fix

# before: replayed callback URL after login completed -> state already deleted
# after: always start a fresh SSO flow
GET /sso/<provider>/login  ->  new state  ->  callback once
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-check that the state still exists and is active
$exists = TableRegistry::getTableLocator()->get('Passbolt/Sso.SsoStates')
    ->find('active')->where(['state' => $state])->count() > 0;
if (!$exists) {
    // expired/consumed/unknown: restart SSO flow before calling the API
}

Type guard

function isActiveState(?SsoState $ssoState): bool {
    return $ssoState !== null && $ssoState->deleted === null && !$ssoState->isExpired();
}

Try / catch

try {
    $ssoState = $getService->getOrFail($state);
} catch (RecordNotFoundException $e) {
    // state unknown, consumed, or expired: start a new SSO login flow
    throw $e;
}

Prevention

When it happens

Trigger: Callback arrives with a state whose row is absent from sso_states (expired and purged, already consumed by a previous callback, or created on a different server/database), or the find('active') filter excludes it because deleted is set.

Common situations: User double-submits or refreshes the callback page after the state was consumed; load-balanced passbolt instances writing to different databases so the initiating server's state is unknown to the callback server; SSO flow left open past expiry and cleanup removed the row; browser back-button replay after a completed login.

Understand the failure class

Background: Record Not Found Errors: "not found", RecordNotFound, and "was not found" — what they mean and how to fix them — this error's family across 28 libraries.

Related errors


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

Appendix: source

Thrown at plugins/PassboltEe/Sso/src/Service/SsoStates/SsoStatesGetService.php:50

     * @throws \Cake\Http\Exception\BadRequestException If given SSO state is invalid.
     */
    public function getOrFail(string $state): SsoState
    {
        if (!SsoState::isValidState($state)) {
            throw new BadRequestException(__('The SSO state is invalid.'));
        }

        /** @var \Passbolt\Sso\Model\Table\SsoStatesTable $ssoStatesTable */
        $ssoStatesTable = $this->fetchTable('Passbolt/Sso.SsoStates');

        try {
            /** @var \Passbolt\Sso\Model\Entity\SsoState $ssoState */
            $ssoState = $ssoStatesTable
                ->find('active')
                ->where(['state' => $state])
                ->firstOrFail();
        } catch (RecordNotFoundException $e) {
            throw new RecordNotFoundException(__('The SSO state does not exist.'), 400, $e);
        }

        return $ssoState;
    }
}

View on GitHub (pinned to 31c1bbc10f)