passbolt/passbolt_api · error · App\Error\Exception\InvalidUserSignatureException
The user signature could not be verified.
Error message
The user signature could not be verified.
What it means
During GPG-backed JWT login the client signs and encrypts a challenge; the server decrypts it with signature verification enabled ($this->gpg->decrypt($armoredChallenge, true)). This error is thrown when decryption succeeds contextually but the signature check fails (InvalidSignatureException), meaning the challenge was not signed by the user's registered OpenPGP private key.
Solutions
- Verify the challenge is signed with the private key matching the fingerprint stored in the user's gpgkeys record
- Re-import or update the user's public key in passbolt if it was rotated (passbolt pro user settings or CLI)
- Check client SDK login code signs the challenge before encrypting (sign-then-encrypt) with the correct key
- Inspect the logged InvalidSignatureException message in error logs for the offending key fingerprint
- Clear stale local keyring entries (gpg --delete-secret-key) and re-run login
Example fix
// before: challenge only encrypted, not signed $challenge = $gpg->encrypt($serverKeyFingerprint, $json); // after: sign with user private key before encrypting $challenge = $gpg->sign($userPrivateKey, $passphrase, $json); $challenge = $gpg->encrypt($serverKeyFingerprint, $challenge);
Defensive patterns
Strategy: try-catch
Validate before calling
const fp = user.gpgkey.fingerprint; const sigOk = gpg.verify(challenge) && gpg.signingKeyFingerprint === fp;
Type guard
function isValidSignedChallenge(c) { return typeof c === 'string' && c.startsWith('-----BEGIN PGP MESSAGE-----'); } Try / catch
try { await login(challenge); } catch (e) { if (e.message.includes('signature could not be verified')) { reSignChallengeWithCorrectKey(); } } Prevention
- Always sign-then-encrypt the challenge with the key matching your registered fingerprint
- Update the server-side public key whenever you rotate your private key
- Keep one active keypair per passbolt account; delete stale keyrings
- Test login with `gpg --decrypt` locally to verify signature validity
When it happens
Trigger: POST to /auth/jwt/login (verifyChallenge called from authenticate) where the armored challenge payload decrypts but its signature does not match the user's public key: challenge signed with a different/wrong key, key rotated on server while client kept old private key, or tampered/replayed payload.
Common situations: Developer regenerated their GPG key locally without updating the key in passbolt; multiple keyrings on the machine and the client signed with the wrong key; a proxy or middleware mangled the armored challenge body; replaying an old challenge after the user's key was revoked.
Related errors
- Could not import the user OpenPGP key.
- The domain is invalid. Expected
- You need to login to access this location.
- Attempt to access an expired verify token.
- Expired refresh token provided.
AI-assisted analysis of passbolt/passbolt_api@31c1bbc10f (2026-09-17).
Data as JSON: /api/errors/4ff3673886d11aca.
Report an issue: GitHub.
Appendix: source
Thrown at plugins/PassboltCe/JwtAuthentication/src/Authenticator/GpgJwtAuthenticator.php:314
}
/**
* @throws \InvalidArgumentException if the challenge is missing
* @throws \Cake\Http\Exception\BadRequestException if the challenge is invalid
* @return string
*/
public function verifyChallenge(): string
{
// Sanity check
$armoredChallenge = $this->request->getData('challenge');
$this->assertArmoredChallenge($armoredChallenge);
// Decrypt and verify signature
try {
$clearTextChallenge = $this->gpg->decrypt($armoredChallenge, true);
} catch (InvalidSignatureException $exception) {
Log::error($exception->getMessage());
throw new InvalidUserSignatureException(__('The user signature could not be verified.'));
} catch (Exception $exception) {
Log::error($exception->getMessage());
throw new BadRequestException(__('The challenge cannot be decrypted.'));
}
// Deserialize JSON
try {
$jsonChallenge = json_decode($clearTextChallenge, true, 2, JSON_THROW_ON_ERROR);
[
'version' => $version,
'domain' => $domain,
'verify_token' => $verifyToken,
'verify_token_expiry' => $verifyTokenExpiry,
] = $jsonChallenge;
} catch (Exception $exception) {
Log::error($exception->getMessage() . "\n" . $clearTextChallenge);
throw new BadRequestException(__('The challenge is invalid. Deserialization failed.'));
}View on GitHub (pinned to 31c1bbc10f)