Our recommendation, up front. We make Proxy Mail QA, so we’re not neutral, but this problem is in your code and the fix is too: no product fixes it for you. We recommend Proxy Mail QA for two parts of the job: test passwords that break your rules on purpose, made for your field’s limits, and finding out what really happened behind a silent form, because if a verification email arrives, your server made the account. It doesn’t find the bug in your code; your browser’s DevTools, or your AI agent reading the code, do that.
First, find out which of three things is happening
Open your browser’s DevTools on the Network tab, then submit a password that breaks your rules, such as one character under the minimum. To get one made for your field, use the Edge-case password list (“Should be rejected”) on the card that Proxy Mail QA for Chrome’s key button opens in the password field, or get_password_variants with your rules. Use a new test address, so you can see whether an email comes.
| What you see | What it means | Look at |
|---|---|---|
| No request is sent | The browser or your code stopped the form, but showed nothing | Causes 1 to 4 |
| A request is sent and the server refuses it (a 400 or 422) | The server did its job; the page didn’t show the answer | Causes 5 and 6 |
| A request is sent and the server accepts it | The server allows the password. A verification email arrives at the test address | Cause 7 |
The third is the one people miss. A form “with no error” may simply have nothing to report, because the server’s rule is looser than the one on the page. In the app, the verification email shows on the test address’s row. An agent or a test can use expect_no_email, which passes only if nothing arrives.
The usual causes
The form never sent anything
novalidatewith nothing in its place. The form, or a form library, turns off the browser’s own messages (novalidate, ornoValidatein React), and your own validation never renders an error.- The rules aren’t on the input. Without
minlength,maxlengthorrequired, the browser has nothing to check. Andvalidity.tooShortis set only after the person types, so a value set by a script can slip past it. - The error exists but is hidden. CSS hides it, it’s rendered under the wrong name (
errors.passwordwhen the field isnewPassword), or it only shows once the field is “touched” and submitting doesn’t mark it touched. - A custom component wraps the input. The form library is connected to the wrapper, not the real
<input>, so it checks an empty value, or the component never passes the error message down.
The server refused it, and the page didn’t say
- The error is swallowed. The code shows “Check your email” whatever the response, uses
catch {}with nothing in it, or only handlesres.ok. Some auth clients don’t throw: Supabase’ssignUpreturns{ data, error }, so atry/catchnever sees a refused password. - The error comes back in a shape the page doesn’t read. The server says
{ "message": "..." }and the page looks forerrors.password. Map each service’s error to the field it’s about.
The server accepted it
- The page’s rule is stricter than the server’s. The page says 15 characters; the auth service’s setting allows 6. Set the minimum in the service’s settings, or in your own server code, and keep it equal to what the page says.
There is an error, but not everyone can tell
- It isn’t announced. It’s shown only in red, far from the field, or off-screen on a phone, and isn’t linked to the field. Screen reader users hear nothing. Link it with
aria-describedby, setaria-invalid="true", and move focus to the field.
A pattern that shows errors reliably
Framework-free, so the idea carries over to React, Vue or anything else:
<form id="signup" novalidate>
<label for="password">Password</label>
<p id="password-rules">At least 15 characters. Any letters, numbers, symbols or spaces.</p>
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
minlength="15"
maxlength="128"
required
aria-describedby="password-rules password-error"
/>
<p id="password-error" class="error" hidden></p>
<button>Create account</button>
</form>
<script>
const form = document.getElementById('signup');
const password = form.elements.password;
const error = document.getElementById('password-error');
// Count characters, not UTF-16 units, and don't rely on validity.tooShort.
function rule(input) {
const length = [...input.value].length;
if (length === 0) return 'Enter a password.';
if (length < input.minLength) return `Use at least ${input.minLength} characters.`;
if (length > input.maxLength) return `Use at most ${input.maxLength} characters.`;
return '';
}
function show(message) {
error.textContent = message;
error.hidden = !message;
password.setAttribute('aria-invalid', message ? 'true' : 'false');
}
password.addEventListener('input', () => {
if (!error.hidden) show(rule(password));
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const message = rule(password);
if (message) {
show(message);
password.focus();
return;
}
const res = await fetch('/api/signup', { method: 'POST', body: new FormData(form) });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
show(body.errors?.password ?? body.message ?? 'Something went wrong. Please try again.');
password.focus();
return;
}
location.assign('/check-your-email');
});
</script>
The server checks the same rules and answers a refused password with a 422 and { "errors": { "password": "Use at least 15 characters." } }, which the page shows at the field.
A note on maxlength: the browser stops typing at the limit and cuts pasted text short without saying so. Set it well above what a password manager makes, or someone saves a password that’s longer than the one your app received.
Ask your agent to find it
With Proxy Mail QA connected in Qoder (Marketplace):
My sign-up form at https://staging.acme.dev/signup shows no error when the password
breaks the rules (15 to 128 characters). Using Proxy Mail QA, get_password_variants
with min_length 15 and max_length 128, and submit each reject value with a new test
address (a plus tag on one address for each). For each, tell me whether a request was
sent, what the server answered, what the page showed, and whether a verification email
arrived (expect_no_email).
Then find the cause in the code and fix it, so the error shows at the field, is
announced to screen readers, and the server enforces the same rule as the page.
Prove it’s fixed
A Playwright test with the proxymail-qa npm package, so it stays fixed:
import { expect, test } from '@playwright/test';
import { ProxyMailQA } from 'proxymail-qa';
const qa = new ProxyMailQA(); // reads PROXYMAIL_QA_API_KEY
test('a too-short password shows an error and makes no account', async ({ page }) => {
const { reject } = await qa.passwordVariants({ minLength: 15, maxLength: 128 });
const tooShort = reject.filter(({ value }) => [...value].length < 15);
const { address } = await qa.createAddress();
for (const [i, { tests, value }] of tooShort.entries()) {
const tagged = address.replace('@', `+short${i}@`);
const submitted = new Date();
await page.goto('/signup');
await page.getByLabel('Email').fill(tagged);
await page.getByLabel('Password').fill(value);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.locator('#password-error'), tests).toBeVisible();
await qa.expectNoEmail(tagged, { since: submitted, timeout: 10 });
}
await qa.deleteAddress(address);
});
expectNoEmail throws as soon as a verification email arrives, which would mean the server made the account. A plus tag gives each try its own account in your app, and waiting on the tagged address only sees mail sent to it.
What Proxy Mail QA doesn’t do here
- Find or fix the bug. It shows you what happened; the fix is in your form and your server.
- Check accessibility beyond this one error. Run axe DevTools, and try the form with a screen reader.
- Choose your rules. For that, see How long should my registration form password be?