CogniPrep has four forms that deal with credentials: sign in, sign up, request a password reset, and set a new password. They are careful forms. Each field has a <label> tied to it with htmlFor, errors are wired through aria-invalid and aria-describedby, and the error slot has a fixed minimum height so an error appearing does not shove the submit button down the page.
Then I checked what the browser sees, and the whole autofill contract was missing.
Three greps on the live site
The four pages are public, so this is reproducible with curl:
$ for p in login signup forgot-password forgot-password/reset; do
curl -s "https://cogniprep.app/$p" | grep -c 'autocomplete='
done
0
0
0
0
Zero on all four. No autocomplete="email", no current-password, no new-password, no name. A password manager gets no hint about which field is which, and Chrome's own heuristics are left to guess from the id and the placeholder.
The second grep is stranger. On /login and /signup there is no email field and no password field in the served HTML:
$ curl -s https://cogniprep.app/login | grep -o '<input[^>]*type="[a-z]*"'
<input type="hidden"
<input type="hidden"
Two hidden inputs, and that is the lot. The word "Password" does not appear anywhere in the document.
Why the fields are absent
Because the email form is a second step. The page renders the OAuth buttons and a "Continue with Email" button, and the form is behind a state flag:
export default function LoginPageClient() {
const [showEmailForm, setShowEmailForm] = useState(false);
return (
<AuthLayout title="Welcome Back" description="Sign in to continue your practice">
{!showEmailForm ? (
<>
<ProviderSigninBlock />
{/* ... terms, divider ... */}
<Button onClick={() => setShowEmailForm(true)}>Continue with Email</Button>
</>
) : (
<LoginForm />
)}
As a design decision that is defensible: most sign-ins go through Google or GitHub, and putting two fields in front of everyone adds work for the majority. What it costs is the autofill moment. A password manager that scans the document on load finds nothing to fill, so the saved credential never gets offered until the user has already decided to type a password. The browser's own "sign in as" prompt has nothing to attach to either.
This is the kind of trade that is worth making deliberately and is easy to make by accident. Ours was made by accident: nothing in the code or the commit message mentions autofill.
The wrapper was never the problem
Every field on sign in and sign up goes through one of two components, and they already pass anything you hand them straight to the input:
const FormInput = React.forwardRef<HTMLInputElement, FormInputProps>(
({ className, label, error, success, helperText, showValidation = true, id, ...props }, ref) => {
const inputId = id || `input-${label?.toLowerCase().replace(/\s+/g, '-')}`;
// ...
<Input
id={inputId}
ref={ref}
aria-invalid={hasError}
aria-describedby={hasError ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined}
{...props}
/>
FormInputProps extends React.InputHTMLAttributes<HTMLInputElement>, so autoComplete="email" would typecheck, render, and work today. It is simply never passed. Across the whole repository there are 19 autoComplete attributes and 16 of them are off, on admin fields and in-test text entry, where switching autofill off is the point. The three real tokens are name and email on the affiliate join form and one given-name inside a practice exercise. The credential forms have none.
What each field actually needs is four tokens: name on the sign-up name field, email on both email fields, current-password on sign in, and new-password on the two reset fields. That last pair is the one worth most: new-password is what makes a password manager offer to generate and then save a password, and without it the reset form looks to the browser like two anonymous password boxes.
The form that predates the wrapper
The reset form was written before FormInput existed and uses the raw UI primitives. Here it is in full, as it ships:
<div className="grid gap-2">
<Label htmlFor="email">Password</Label>
<Input id="password" type="password" placeholder="Enter New Password" name="password" required />
<Input id="confirm_password" type="password" placeholder="Confirm Password" name="confirm_password" required />
The label says "Password", points at email, and there is no element with that id on the page. So clicking the label focuses nothing, and a screen reader reading the first field gets no accessible name from it. The second field has no label at all, only a placeholder, which disappears the moment you type.
That one is checkable from the outside too, and it is my favourite kind of bug report because it needs no account:
$ curl -s https://cogniprep.app/forgot-password/reset | grep -o 'for="[a-z_]*"'
for="email"
$ curl -s https://cogniprep.app/forgot-password/reset | grep -o 'id="\(password\|confirm_password\|email\)"'
id="password"
id="confirm_password"
One for, two inputs, and no match between them.
What the audit is worth
Nothing here is a crash, which is exactly why it survived. The forms validate correctly, they announce errors correctly, and the only thing they get wrong is a contract with software that is not the test suite: the browser's autofill engine and the password manager sitting on top of it. There is no type error to catch it and no assertion that fails. The way you find it is to read the rendered HTML as a machine would, which takes one grep per page.
Three things came out of it, in priority order. The two reset fields need new-password and real labels, because that form is the one a user reaches while already annoyed. Sign in needs email and current-password. And the "Continue with Email" step needs a decision rather than a default: either accept that autofill starts one click late, or render the form and collapse the OAuth block instead.
See it
Open cogniprep.app/login and run this in the console before touching anything:
[...document.querySelectorAll('input')].map(i => [i.name, i.type, i.autocomplete])
You get two hidden inputs. Click "Continue with Email" and run it again: the email and password fields appear, and the third column is empty for both. Then load cogniprep.app/forgot-password/reset and run the same line to see two password fields whose only description is a placeholder.
The rest of the product is the part that actually works: the practice catalogue covers 56 assessment providers, and the free tier needs no card.
Top comments (1)
Good write-up. Checking the served HTML the way a password manager would is the trick I’m taking from this. Those forms pass every test and still give the browser nothing to work with, and no type check or assertion was ever going to complain.