passbolt/passbolt_api · error · UnauthorizedException

The Duo state should match the authentication token state.

Error message

The Duo state should match the authentication token state.

What it means

Duo's security model relies on the client returning the same 'state' value it was given at flow start (CSRF-style protection). This error is thrown as UnauthorizedException when the state received in the Duo callback does not exactly string-match the state stored in the passbolt authentication token data. It indicates the callback did not originate from the same Duo flow that issued the token, or the state was altered in transit.

Solutions

  1. Ensure the same browser session that started the Duo prompt completes it — discard the stale tab and restart the flow
  2. Restart MFA setup to mint a fresh token and new state, then complete Duo in one flow only
  3. Compare (and log at debug level) the callback state vs the stored state to spot encoding differences (e.g. double URL-encoding)
  4. Never construct the callback URL manually — follow the redirect Duo provides so the state is passed through untouched

Example fix

// before (client-side manual callback)
header('Location: /mfa/verify?token=' . $token . '&state=' . urlencode($oldState));
// after
// let Duo redirect carry the original state; server compares raw values
$service->consumeAndVerifyAuthenticationToken($uac, $type, $token, $callbackState);
Defensive patterns

Strategy: validation

Validate before calling

if (!is_string($callbackState) || $callbackState === '') {
    throw new \Cake\Http\Exception\BadRequestException('Missing Duo state in callback.');
}

Try / catch

try {
    $authToken = $service->consumeAndVerifyAuthenticationToken($uac, $type, $token, $state);
} catch (\Cake\Http\Exception\UnauthorizedException $e) {
    // state mismatch: CSRF-like condition, do not proceed
    throw new \Cake\Http\Exception\UnauthorizedException('Duo state verification failed.');
}

Prevention

When it happens

Trigger: The 'state' query parameter returned by Duo differs from the token's stored state: a different browser/tab completed the Duo prompt; the state was tampered with in the URL; two MFA flows were started and the callback from one is paired with the token of the other; the state was URL-encoded/decoded differently.

Common situations: User opens the setup page in two tabs and completes Duo in the wrong one; a proxy or redirect rewrites query parameters; tests reuse a fixed state string across tokens; session loss causes a fresh token to be issued but Duo returns the old state.

Understand the failure class

Related errors


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

Appendix: source

Thrown at plugins/PassboltCe/MultiFactorAuthentication/src/Service/Duo/MfaDuoCallbackAuthenticationTokenService.php:119

    /**
     * Assert the Duo callback authentication token state value.
     *
     * @param \App\Model\Entity\AuthenticationToken $authToken The callback authentication token
     * @param string $duoState The Duo callback state
     * @return void
     * @throws \Cake\Http\Exception\InternalErrorException if the callback authentication token does not have state defined
     * @throws \Cake\Http\Exception\UnauthorizedException if the callback authentication token state value does not match the Duo callback state
     */
    private function assertDuoStateMatchesAuthenticationTokenState(
        AuthenticationToken $authToken,
        string $duoState
    ): void {
        $authTokenState = $authToken->getDataValue('state');
        if (empty($authTokenState)) {
            throw new InternalErrorException(__('An authentication token state is required.'));
        }
        if ($authTokenState !== $duoState) {
            throw new UnauthorizedException(__('The Duo state should match the authentication token state.'));
        }
    }
}

View on GitHub (pinned to 31c1bbc10f)