checklistauthenticationplaywrightautomationsecurityqa

Registration & login testing checklist — with a Playwright autotest for every item

Login is the first screen a user sees, and at the same time a favorite spot for production incidents: “can’t log in” is always a p1, because the product’s entire value sits behind that door. And yet auth usually gets tested by leftovers: “well, login works, doesn’t it.” Continuing the format of the search testing post: every checklist item — immediately followed by how to automate it in Playwright. Auth fits this format perfectly: the flow is stable, changes rarely, and runs on every release — an ideal automation candidate.

1. Form validation: boundaries and garbage

The classics from the form testing checklist applied to signup: empty fields, spaces instead of an email, an email without a domain, a password exactly one character short of the minimum, unicode and emoji in the password (allowed? validated identically on signup and login?).

Automated via parametrization — one test, an array of cases:

const invalid = ['', '   ', 'no-at-sign', 'a@b', '<script>@x.com'];
for (const email of invalid) {
  test(`signup rejects email: "${email}"`, async ({ page }) => {
    await page.goto('/signup');
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Password').fill('Valid-Pass-123');
    await page.getByRole('button', { name: 'Create account' }).click();
    await expect(page.getByRole('alert')).toBeVisible();
    await expect(page).toHaveURL(/signup/); // not let through
  });
}

2. The login error doesn’t reveal what exactly was wrong

If the system says “user not found” for a non-existent email and “wrong password” for an existing one, you’ve gifted an attacker a way to enumerate your user base (user enumeration). The OWASP Authentication Cheat Sheet explicitly requires an identical response in both cases. Same on signup (“email already taken” is enumeration too — usually a conscious trade-off there) and on password reset (“if the account exists, we’ve sent an email”).

The autotest compares the two messages:

test('login does not reveal account existence', async ({ page }) => {
  const msgFor = async (email: string) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Password').fill('definitely-wrong');
    await page.getByRole('button', { name: 'Sign in' }).click();
    return page.getByRole('alert').textContent();
  };
  const existing = await msgFor('[email protected]');
  const missing = await msgFor('[email protected]');
  expect(existing).toBe(missing); // same text — nothing to enumerate
});

3. Password reset: the token works exactly once

The most under-tested flow in auth — because email is involved. What needs checking: the email arrives for an existing account, the token opens the change form, a used token is invalid (open the link a second time), an expired token is rejected, the old password stops working after the change, the new one works, and per the OWASP Forgot Password Cheat Sheet active sessions should be terminated after a password change.

Don’t drag real email into e2e — on the test environment, mail goes to an interceptor (MailHog/Mailpit) that has an API. The test picks the link from there:

test('reset token is single-use', async ({ page, request }) => {
  await page.goto('/forgot');
  await page.getByLabel('Email').fill(user.email);
  await page.getByRole('button', { name: 'Reset password' }).click();

  const mail = await request.get('http://mailpit:8025/api/v1/messages');
  const link = extractResetLink(await mail.json());

  await page.goto(link);
  await page.getByLabel('New password').fill('New-Pass-456');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page).toHaveURL(/login/);

  await page.goto(link); // second attempt with the same token
  await expect(page.getByText(/link is invalid/i)).toBeVisible();
});

4. Logout: the session actually dies

Everyone has a “sign out” button, but does anyone verify the session is truly invalidated? Two mandatory cases: after logout, the browser’s back button doesn’t show private data (from cache or, worse, via a live session), and a direct private URL redirects to login.

test('private data unreachable after logout', async ({ page }) => {
  await login(page);
  await page.goto('/settings');
  await page.getByRole('button', { name: 'Sign out' }).click();
  await expect(page).toHaveURL(/login/);

  await page.goBack(); // the back button
  await expect(page).toHaveURL(/login/);

  await page.goto('/settings'); // direct navigation
  await expect(page).toHaveURL(/login/);
});

5. Sessions: two tabs, remember me, expiry

Session behavior across tabs and devices is where “works on my machine” gets especially treacherous. Check: logging in from a second tab doesn’t kill the first (or does — if that’s the policy, but deliberately); remember me survives a browser restart, its absence doesn’t; session expiry mid-work leads to login with a clear message and, ideally, a return to the same place after signing in.

Two tabs in Playwright are two contexts or two pages of one context, depending on what you’re modeling (contexts don’t share cookies — that’s “two browsers”; pages of one context do — that’s “two tabs”):

test('login in a second tab does not break the first', async ({ context }) => {
  const tab1 = await context.newPage();
  await login(tab1);
  const tab2 = await context.newPage();
  await tab2.goto('/dashboard');           // already signed in — shared session
  await expect(tab2.getByTestId('user-menu')).toBeVisible();
  await tab1.reload();
  await expect(tab1.getByTestId('user-menu')).toBeVisible(); // first tab alive
});

And an expired session is arranged deterministically by hand — clear the cookies and watch how the UI survives the next request:

test('expired session → login, not a blank screen', async ({ page, context }) => {
  await login(page);
  await context.clearCookies();            // "session expired"
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page).toHaveURL(/login/);
  await expect(page.getByText(/session expired/i)).toBeVisible();
});

6. Brute-force lockout: the UI at 429

Rate limiting itself is better left out of e2e — really making N attempts is slow and flaky (the limit is shared per environment, parallel workers lock each other out). Split it: that the limit exists — verified at the API-test or environment-config level; that the UI shows the lockout sanely — mock the response, as in the search post:

test('UI shows the lockout instead of going silent', async ({ page }) => {
  await page.route('**/api/login', r => r.fulfill({
    status: 429,
    json: { error: 'too_many_attempts', retryAfter: 300 },
  }));
  await page.goto('/login');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Password').fill('wrong');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByText(/too many attempts/i)).toBeVisible();
});

7. Cookies and small security hygiene

Not a pentest, but cheap checks that catch configuration regressions: the session cookie has HttpOnly (JS can’t read it), Secure, SameSite; the password never lands in a URL or logs; the password field allows paste (blocking paste is an antipattern — it breaks password managers) and has show/hide.

The autotest reads cookie attributes directly:

test('session cookie is protected', async ({ page, context }) => {
  await login(page);
  const session = (await context.cookies()).find(c => c.name === 'session');
  expect(session?.httpOnly).toBe(true);
  expect(session?.secure).toBe(true);
  expect(session?.sameSite).toBe('Lax');
});

8. The format bonus: don’t log in inside every test

The pattern that pays for this article: UI login should be a test, not the setup of all tests. Running the full UI login before each of two hundred tests is slow and flaky. Playwright has a built-in mechanism for this — storageState: a separate setup project logs in once and saves the state, all other tests start already authenticated:

// auth.setup.ts — runs once
setup('authenticate', async ({ page }) => {
  await login(page);
  await page.context().storageState({ path: '.auth/user.json' });
});
// playwright.config.ts: use: { storageState: '.auth/user.json' }

The login flow itself stays covered by its own explicit tests — the items above.

What in auth should NOT go into e2e

  • Real email delivery — whether the message reaches Gmail and stays out of spam is not something e2e can verify; that’s deliverability monitoring, a separate discipline.
  • Third-party OAuth (“sign in with Google/Apple”) — you don’t control their UI, tests through real providers flake beautifully and hit their anti-bot protection. Mock the provider at the boundary; a live pass — by hand before the release.
  • CAPTCHA — it exists precisely so that you can’t automate it. On the test environment it’s disabled by a flag, but that it’s enabled in production — check by hand (and that item gets forgotten more often than you’d think).
  • Timing attacks and brute force — security tooling, not Playwright.

The full checklist

Signup: boundaries and garbage in fields · duplicate email → no enumeration (or a conscious trade-off) · confirmation email and its re-send · password: paste allowed, show/hide, unicode. Login: valid/invalid credentials · identical error for “no such user” and “wrong password” · case and spaces in the email · redirect back to the original page after signing in. Password reset: single-use token · expired one rejected · old password dies, sessions terminated · “email sent” identical for any address. Sessions: logout kills the session (back button + direct URL) · two tabs/devices · remember me · expiry mid-action — with a message. Security minimum: HttpOnly/Secure/SameSite cookies · password never in URLs/logs · rate limit exists (API level) and the UI displays it (mocked 429) · CAPTCHA enabled in production. Test infrastructure: storageState instead of logging in per test · email via an interceptor with an API.

Auth is a rare case where nearly the whole checklist automates honestly: the flow is deterministic, changes rarely, and the cost of a regression is maximal. If a project has any autotests at all — login should be covered first.