input event of both fields, call confirm.setCustomValidity(pw.value === confirm.value ? '' : 'Passwords do not match'). Clearing it with '' is required, or the form stays invalid forever. pattern can't compare two fields. In React, use a validate rule with watch('password'), and always check again on the server.Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.
Create free accountThe HTML: two password fields
Start with two input type=password fields. Give both a name, required and minlength="8", and set autocomplete="new-password" so password managers offer to generate a strong password instead of filling an old one:
<form id="signup">
<label for="password">Password</label>
<input id="password" name="password" type="password"
required minlength="8" autocomplete="new-password" />
<label for="confirm">Confirm password</label>
<input id="confirm" name="confirm_password" type="password"
required minlength="8" autocomplete="new-password" />
<button type="submit">Create account</button>
</form>The browser already checks that both fields are filled in and at least 8 characters long. What it can't do on its own is check that they are the same. That part needs a few lines of JavaScript.
JavaScript: setCustomValidity on the input event
Run one check whenever either field changes. If the values differ, give the confirm field a custom error. If they match, clear it:
const pw = document.querySelector("#password");
const confirm = document.querySelector("#confirm");
function checkMatch() {
confirm.setCustomValidity(
pw.value === confirm.value ? "" : "Passwords do not match"
);
}
pw.addEventListener("input", checkMatch);
confirm.addEventListener("input", checkMatch);Listen on both fields. If you only listen on the confirm field, a user who fixes the first password after typing the second one is still blocked by the old error.
Always reset it to an empty string. Any non-empty text passed to setCustomValidity() marks the field invalid until you call setCustomValidity(""). If you only set the error and never clear it, validity.valid stays false and the form can never be submitted, even after the passwords match. The same stuck-message bug, and how to word other messages, is covered in how to change the HTML5 validation message.
Show the mismatch message live
By default the browser only shows the message when the user presses submit. To show it while they type, call reportValidity() on the confirm field once it has a value:
confirm.addEventListener("input", () => {
checkMatch();
if (confirm.value) confirm.reportValidity();
});The native bubble can feel pushy on every keystroke. A quieter option is CSS. The :user-invalid pseudo-class only matches after the user has interacted with the field, so it doesn't paint the field red before they start typing:
<input id="confirm" ... aria-describedby="confirm-error" />
<p id="confirm-error" class="error">Passwords do not match</p>
<style>
.error { display: none; color: #b42318; }
#confirm:user-invalid { border-color: #b42318; }
#confirm:user-invalid + .error { display: block; }
</style>Because the custom validity makes the field invalid, :user-invalid also matches while the passwords differ, so the same rule covers empty, too-short and mismatched values. Keep the error text in a real element linked with aria-describedby so screen readers read it.
Why the pattern attribute can't compare two fields
The pattern attribute is a regular expression tested against that one field's value. It has no way to read another input, so there is no pattern that means "same as the password field".
You can copy the password into the confirm field's pattern with JavaScript, but special characters such as ., * or ( would then act as regex syntax, so a password like a.b would also match axb. setCustomValidity() compares the strings exactly and is simpler.
Use pattern for rules about the password itself, such as "at least one digit and one letter". You can build and test those expressions with the free password regex generator.
React: useState and React Hook Form
With plain useState, keep both values in state and work out the error while rendering:
function SignupForm() {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const mismatch = confirm.length > 0 && password !== confirm;
function onSubmit(e) {
if (password !== confirm) e.preventDefault();
}
return (
<form onSubmit={onSubmit}>
<input type="password" name="password" required minLength={8}
autoComplete="new-password"
value={password} onChange={(e) => setPassword(e.target.value)} />
<input type="password" name="confirm_password" required minLength={8}
autoComplete="new-password" aria-invalid={mismatch}
value={confirm} onChange={(e) => setConfirm(e.target.value)} />
{mismatch && <p role="alert">Passwords do not match</p>}
<button type="submit" disabled={mismatch}>Create account</button>
</form>
);
}With React Hook Form, add a validate rule to the confirm field and read the password with watch:
const {
register,
watch,
handleSubmit,
formState: { errors },
} = useForm();
<input type="password" autoComplete="new-password"
{...register("password", { required: true, minLength: 8 })} />
<input type="password" autoComplete="new-password"
{...register("confirmPassword", {
validate: (v) => v === watch("password") || "Passwords do not match",
})} />
{errors.confirmPassword && <p role="alert">{errors.confirmPassword.message}</p>}If you validate with a zod schema, put the check on the object with .refine() and a path, as shown in React Hook Form with zod validation. For more on watch and when it re-renders, see React Hook Form watch and setValue.
Check again on the server, and never email passwords
Browser checks are for the user's comfort only. Anyone can turn off JavaScript or send a request directly, so your server must compare the two values again and reject the request if they differ:
// Node / Express example
if (req.body.password !== req.body.confirm_password) {
return res.status(400).json({ error: "Passwords do not match" });
}
const hash = await bcrypt.hash(req.body.password, 12);Store only a slow hash (bcrypt, scrypt or Argon2), never the plain password, and never send it by email or write it to logs. Your sign-up form should post to your own auth backend.
A contact, quote or lead form should not ask for a password at all. Those submissions are emailed and stored as plain text, which is fine for a name and a message but never for a secret.
FAQ
How do I check if password and confirm password match in JavaScript?
Listen for the input event on both fields and call confirm.setCustomValidity(pw.value === confirm.value ? '' : 'Passwords do not match'). The browser then treats the confirm field as invalid while the values differ, blocks the submit and shows your message in its validation bubble.
Why does my form never submit after setCustomValidity?
Any non-empty string passed to setCustomValidity marks the field invalid until you call setCustomValidity('') again. If you only set the mismatch message and never clear it when the passwords match, the form stays invalid forever. Always set an empty string in the matching branch.
Can the HTML pattern attribute check that two passwords match?
No. pattern is a regular expression tested against one field's own value only. It cannot read another input, so it can enforce rules like length or a digit, but comparing password and confirm password always needs JavaScript (or a check on the server).
How do I validate confirm password in React Hook Form?
Register the confirm field with a validate rule that compares it to the password: validate: v => v === watch('password') || 'Passwords do not match'. The message appears in errors.confirmPassword.message. With zod, use .refine() on the object with path: ['confirmPassword'] instead.
References: MDN: setCustomValidity() and MDN: :user-invalid.
Building the rest of your site's forms? A form to email endpoint from splitforms handles contact and lead forms (the kind that never ask for a password) with plain HTML, no backend needed.