{"record":{"id":"6c7155cc2dd9cf48","repo":"getgrav/grav","slug":"passwords-did-not-match","errorCode":null,"errorMessage":"Passwords did not match.","messagePattern":"Passwords did not match\\.","errorType":"validation","errorClass":"RuntimeException","httpStatus":null,"severity":"error","filePath":"system/src/Grav/Common/Flex/Types/Users/UserObject.php","lineNumber":625,"sourceCode":"        if ($isNewUser) {\n            $newKey = $this->getKey();\n\n            // Prevent overwriting an existing account when a low-privileged user\n            // creates a new user with an already-taken username (GHSA-rr73-568v-28f8).\n            // Applies to every storage implementation, not just FileStorage.\n            $storage = $this->getFlexDirectory()->getStorage();\n            if ($storage->hasKey($newKey)) {\n                throw new RuntimeException('User account with this username already exists');\n            }\n\n            $this->setStorageKey($newKey);\n        }\n\n        $password = $this->getProperty('password') ?? $this->getProperty('password1');\n        if (null !== $password && '' !== $password) {\n            $password2 = $this->getProperty('password2');\n            if (!\\is_string($password) || ($password2 && $password !== $password2)) {\n                throw new \\RuntimeException('Passwords did not match.');\n            }\n\n            $this->setProperty('hashed_password', Authentication::create($password));\n        }\n        $this->unsetProperty('password');\n        $this->unsetProperty('password1');\n        $this->unsetProperty('password2');\n\n        // Backwards compatibility with older plugins.\n        $fireEvents = $this->isAdminSite() && $this->getFlexDirectory()->getConfig('object.compat.events', true);\n        $grav = $this->getContainer();\n        if ($fireEvents) {\n            $self = $this;\n            $grav->fireEvent('onAdminSave', new Event(['type' => 'flex', 'directory' => $this->getFlexDirectory(), 'object' => &$self]));\n            if ($self !== $this) {\n                throw new RuntimeException('Switching Flex User object during onAdminSave event is not supported! Please update plugin.');\n            }\n        }","sourceCodeStart":607,"sourceCodeEnd":643,"githubUrl":"https://github.com/getgrav/grav/blob/6040efed04efa69b8209448ed81308e7c24147c2/system/src/Grav/Common/Flex/Types/Users/UserObject.php#L607-L643","documentation":"Thrown from the Flex user object's save pipeline (UserObject.php:625). When a non-empty plain-text 'password' (or 'password1') property is set, Grav requires it to be a string and to equal a non-empty 'password2' confirmation property; otherwise it throws before hashing via Authentication::create(). The check is skipped when password2 is empty/null, so the error means a confirmation value was present and disagreed (or the password itself was not a scalar).","triggerScenarios":"Calling $user->save() after setProperty('password', ...) and setProperty('password2', ...) with two different non-empty values; posting a form where 'password' arrives as an array (e.g. password[]) so !is_string($password) fires; API/headless user creation that forwards a raw request body containing a stale password2 alongside a new password.","commonSituations":"Registration or profile-edit forms whose client-side confirmation was bypassed; test fixtures that set password but forget to clear password2; double form submission or browser autofill making the two fields diverge; admin plugins that copy password1 into the object while keeping the original password2 from the same payload.","solutions":["Make the two submitted values identical before saving, or unset password2 entirely — an empty/falsy confirmation skips the comparison entirely.","Ensure 'password' reaches the object as a scalar string: reject array values at the form/API boundary (e.g. expect scalar in your validation) before assigning the property.","Catch RuntimeException around save() in your controller and re-render the form with a 'passwords did not match' message instead of a 500 error."],"exampleFix":"// before\n$user->setProperty('password', $data['password']);\n$user->setProperty('password2', $data['password_confirm']);\n$user->save(); // RuntimeException('Passwords did not match.')\n\n// after\nif (!\\is_string($data['password']) || ($data['password2'] ?? '') !== $data['password']) {\n    throw new \\InvalidArgumentException('Passwords did not match.');\n}\n$user->setProperty('password', $data['password']);\n$user->save(); // password2 left unset — comparison skipped","handlingStrategy":"validation","validationCode":"$pass  = $form->getValue('password') ?? $form->getValue('password1');\n$confirm = $form->getValue('password2');\nif (null !== $pass && '' !== $pass) {\n    if (!\\is_string($pass) || ($confirm !== null && $confirm !== '' && $confirm !== $pass)) {\n        // reject before save(): show 'passwords did not match'\n    }\n}","typeGuard":"function passwordsAgree(mixed $pass, mixed $confirm): bool\n{\n    return null === $pass || '' === $pass\n        || (\\is_string($pass) && (!$confirm || $confirm === $pass));\n}","tryCatchPattern":"try {\n    $user->save();\n} catch (\\RuntimeException $e) {\n    if (str_contains($e->getMessage(), 'Passwords did not match')) {\n        // re-render form with confirmation error; do not retry unchanged\n    }\n    throw $e;\n}","preventionTips":["Render password and password2 from the same form object so they are validated together.","Validate is_string($password) at the request boundary before assigning it to the object.","In API endpoints, compare then unset password2 so a stale confirmation can never reach save()."],"tags":["user-management","password","validation","flex","accounts"],"backgroundTag":"password-confirmation-mismatch","analyzedSha":"6040efed04efa69b8209448ed81308e7c24147c2","analyzedAt":"2026-08-17T05:07:31.593Z","schemaVersion":2},"datasetVersion":"2026-08-17T09:17:11.063Z"}