Proxy Mail QA

Why is my registration form not showing an error for passwords that don't meet the criteria?

Someone types a password that's too short, presses **Create account**, and nothing happens. Or the page moves on as if all was well. The cause is almost always one of eight things, and one test tells you which group to look in.

Updated

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

  1. novalidate with nothing in its place. The form, or a form library, turns off the browser’s own messages (novalidate, or noValidate in React), and your own validation never renders an error.
  2. The rules aren’t on the input. Without minlength, maxlength or required, the browser has nothing to check. And validity.tooShort is set only after the person types, so a value set by a script can slip past it.
  3. The error exists but is hidden. CSS hides it, it’s rendered under the wrong name (errors.password when the field is newPassword), or it only shows once the field is “touched” and submitting doesn’t mark it touched.
  4. 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

  1. The error is swallowed. The code shows “Check your email” whatever the response, uses catch {} with nothing in it, or only handles res.ok. Some auth clients don’t throw: Supabase’s signUp returns { data, error }, so a try/catch never sees a refused password.
  2. The error comes back in a shape the page doesn’t read. The server says { "message": "..." } and the page looks for errors.password. Map each service’s error to the field it’s about.

The server accepted it

  1. 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

  1. 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, set aria-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:

HTML
<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):

Prompt
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:

TypeScript
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

Try it on your next sign-up test

Free plan, no credit card. Create an address and watch the code or link arrive.

Create a free accountOpen the app

Built your app with an AI tool? Pick yours