How to Automate Simple Form Login with Puppeteer
Use Puppeteer Locators to fill a login form, coordinate navigation safely, and verify authenticated state with reliable success checks.

To automate a simple HTML form login with Puppeteer, open the login page, fill the username and password controls with Puppeteer Locators, click the submit control, and then verify an application-specific authenticated-state signal. If submitting the form causes navigation, install waitForNavigation() at the same time as the click with Promise.all(). If the page is a single-page application (SPA) that updates without navigation, wait for a dashboard element, account label, or another explicit success condition instead.
This pattern handles ordinary web forms. It does not bypass CAPTCHA, bot checks, multi-factor authentication, or access controls. Use it only on sites and accounts you are authorized to automate, and keep credentials outside source code.
1. Minimal Puppeteer login script
Install Puppeteer in a Node.js project:
npm install puppeteer
Create login.mjs:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.goto('https://example.test/login', {
waitUntil: 'domcontentloaded'
});
await page.locator('input[name="username"]').fill(process.env.LOGIN_USER);
await page.locator('input[name="password"]').fill(process.env.LOGIN_PASSWORD);
const [response] = await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.locator('button[type="submit"]').click(),
]);
// A response can be null for same-document History API changes.
// Verify a site-specific authenticated signal instead.
await page.locator('[data-testid="account-menu"]').wait();
console.log('Login succeeded:', page.url());
} finally {
await browser.close();
}
Run it with credentials supplied through the environment:
LOGIN_USER='alice@example.com' LOGIN_PASSWORD='use-a-secret-store' node login.mjs
Replace the URL, selectors, and data-testid value with the authorized site’s actual markup. The example uses Locators because the official Puppeteer interaction guide recommends them for selecting and interacting with elements. Locators wait for useful action preconditions, including element availability, visibility, enabled state, and stable layout. See the Puppeteer page interactions guide for the current API.
2. Inspect the form before writing selectors
The login page determines whether your script is reliable. In browser developer tools, inspect:
- The username field’s
name,id, label, or test attribute. - The password field’s
name,id, or type. - The submit button or submit input.
- The element that exists only after authentication, such as an account menu or dashboard heading.
- Whether the form is inside an iframe or shadow DOM.
Prefer stable attributes owned by the application, such as data-testid, an accessible label, or a semantic name. Avoid selectors based on generated CSS class names, deep positional selectors, and text that changes with localization. A selector that matches several fields can fill the wrong control, so check uniqueness while debugging:
console.log('username matches:', await page.locator('input[name="username"]').count());
console.log('password matches:', await page.locator('input[name="password"]').count());
console.log('submit matches:', await page.locator('button[type="submit"]').count());
3. Filling controls with Locators
Locator.fill() supports input and textarea controls, as well as select and contenteditable elements. For checkbox, radio, and switch controls, use a boolean value with the locator API instead of typing text. The exact method names can vary with the Puppeteer version installed in your project, so check the versioned API reference when using less common controls.
await page.locator('input[name="username"]').fill(process.env.LOGIN_USER ?? '');
await page.locator('input[name="password"]').fill(process.env.LOGIN_PASSWORD ?? '');
// Example of an optional “remember me” checkbox:
await page.locator('input[name="remember"]').setChecked(true);
Do not print the values of password fields, environment variables, request headers, or cookies. When a login page has a hidden anti-forgery token, a normal browser form submission usually includes it automatically because the page’s JavaScript and DOM remain active.
4. Coordinate the submit click and navigation
A common race condition occurs when code clicks first and starts waiting for navigation afterward. The page can navigate before the wait is installed. Create the navigation promise before the click:

const [response] = await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle2' }),
page.locator('button[type="submit"]').click(),
]);
waitForNavigation() covers navigation such as a new document load, URL changes, and history changes. Its result can be null for a same-document History API or anchor change, so a non-null response is not proof that authentication worked. Also, networkidle2 can take a long time on pages with analytics, polling, or long-lived connections. Use domcontentloaded and then wait for a known authenticated element when that is more predictable.
For a form that submits with a normal HTTP request, another useful variant is:
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.locator('form#login-form').press('Enter'),
]);
Use one submit action only. Running both a click and an Enter keypress can submit twice and create confusing results.
5. Handle single-page applications without navigation
Many React, Vue, and other client-rendered applications intercept the form submission. The URL may stay the same while an authenticated session is established and the page changes in place. In that case, do not wait forever for navigation. Wait for the application’s success signal:
await page.locator('button[type="submit"]').click();
await page.locator('[data-testid="dashboard"]').wait({ timeout: 15000 });
const heading = await page.locator('h1').innerText();
console.log('Authenticated dashboard:', heading);
Good success signals include an account menu, a logout button, a dashboard route, a user-specific heading, or a request that is made only for authenticated users. A successful click, a changed button label, or a lack of an exception is not enough. Check for an error message as well:
const loginError = page.locator('[role="alert"]');
if (await loginError.isVisible().catch(() => false)) {
throw new Error(`Login failed: ${await loginError.innerText()}`);
}
6. Complete reusable helper
The following helper supports both navigation and SPA-style success checks. It keeps the success condition explicit at the call site.
import puppeteer from 'puppeteer';
async function login(page, {
url,
username,
password,
usernameSelector = 'input[name="username"]',
passwordSelector = 'input[name="password"]',
submitSelector = 'button[type="submit"]',
successSelector,
navigates = true,
}) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.locator(usernameSelector).fill(username);
await page.locator(passwordSelector).fill(password);
if (navigates) {
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.locator(submitSelector).click(),
]);
} else {
await page.locator(submitSelector).click();
}
if (successSelector) {
await page.locator(successSelector).wait({ timeout: 15000 });
}
const error = page.locator('[role="alert"]');
if (await error.isVisible().catch(() => false)) {
throw new Error(`The login page reported an error: ${await error.innerText()}`);
}
}
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await login(page, {
url: 'https://example.test/login',
username: process.env.LOGIN_USER ?? '',
password: process.env.LOGIN_PASSWORD ?? '',
successSelector: '[data-testid="account-menu"]',
navigates: true,
});
console.log('Authenticated URL:', page.url());
} finally {
await browser.close();
}
7. Sessions, cookies, and repeated runs
A browser context owns cookies and other session state. If each run should start clean, create a new incognito browser context or launch a fresh browser. If repeated runs are authorized and you need to avoid logging in every time, save and restore cookies carefully:
const cookies = await page.cookies();
await page.setCookie(...cookies);
await page.deleteCookie(...cookies);
Restored cookies can expire, be bound to a device, or be invalidated by the application. Treat a restored cookie as an optimization, never as proof of a valid session. After restoring state, visit a protected page and verify the same success signal used after a fresh login.
8. Iframes, shadow DOM, and unusual forms
Login inside an iframe
Page-level selectors do not cross iframe boundaries. Find the frame, then use its locator:
const frame = page.frames().find(f => f.url().includes('/embedded-login'));
if (!frame) throw new Error('Login iframe was not found');
await frame.locator('input[name="username"]').fill(process.env.LOGIN_USER ?? '');
await frame.locator('input[name="password"]').fill(process.env.LOGIN_PASSWORD ?? '');
await frame.locator('button[type="submit"]').click();
Shadow DOM
Use a locator that targets the host and then the element inside the shadow tree, following the current Puppeteer locator guidance. If a component library exposes accessible labels or test attributes, prefer those over internal class names.
HTTP authentication
page.authenticate() is for HTTP Basic or Digest authentication. It is not the normal solution for an HTML username-and-password form:
await page.authenticate({
username: process.env.HTTP_USER ?? '',
password: process.env.HTTP_PASSWORD ?? '',
});
await page.goto('https://protected.example.test/');
For a traditional form, interact with the fields and submit control instead. Puppeteer’s authentication method turns request interception on internally, which is a separate mechanism from form interaction.
9. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
TimeoutError while filling |
Wrong selector, slow page, iframe, or hidden form | Inspect the markup, wait for the correct frame, and use a stable accessible or test selector. |
| Click finishes but login did not happen | Invalid credentials, client-side validation, or a server error | Wait for a success selector and inspect visible alert text; do not treat the click as proof. |
| Navigation wait times out | SPA submission or a page with persistent network requests | Use a target-specific success element, or change the navigation wait condition to domcontentloaded. |
Navigation response is null |
Same-document History API or anchor change | Check the URL and authenticated UI state directly. |
| Selector matches several fields | Generic selector such as input |
Use a field name, label, role, or test attribute and verify the match count. |
| Login works manually but not headless | Timing, viewport, user-agent differences, or bot protection | Capture a screenshot and console output for diagnosis, wait on real UI state, and follow the site’s permitted automation policy. |
| Credentials disappear after submit | Page re-rendered and replaced the form | Fill immediately before submission and avoid caching element handles across renders; Locators re-resolve elements. |
| Protected page redirects to login | Cookies were not retained or the session expired | Use one browser context for the flow, inspect cookies, and verify the protected URL after login. |
10. Reliability, performance, and cost considerations
- Reliability: use Locators, stable selectors, explicit success checks, and bounded timeouts. Log URLs, status text, and error messages, but never secrets.
- Performance: reuse a browser for multiple authorized tasks when isolation permits. Avoid waiting for global network idle when a specific element tells you the page is ready. Use a realistic viewport so responsive layouts expose the controls you expect.
- Failure handling: close pages and browsers in
finallyblocks. Retry only transient failures such as an unavailable page; do not blindly retry invalid credentials or account lockouts. - Security: load secrets from an environment or secret manager, restrict log access, and remove saved cookies when no longer needed. Never commit credentials or session files.
- Authorization: CAPTCHA, MFA, bot checks, and rate limits require the site owner’s approved integration path. A browser script should not be used to defeat them.
11. Or skip the browser setup
If your goal is a screenshot of a page after you have an authorized way to make it publicly or otherwise accessibly available, ScreenshotNeo provides a single HTTP request instead of maintaining Puppeteer infrastructure. It can accept custom cookies, headers, a user agent, and Authorization when your use case permits them, along with waits, selectors, JavaScript, and custom CSS.

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API documentation for all options. cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
You get 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
12. FAQ
Should I use a CSS selector or an accessible locator?
Use the most stable selector the application provides. Accessible labels, roles, names, and dedicated test attributes usually survive layout changes better than generated classes.
Do I always need waitForNavigation()?
No. Use it when the submit action actually navigates. For an SPA, wait for the authenticated UI element or another application-specific state change.
Does a successful navigation prove login succeeded?
No. A redirect can lead back to the login page, an error page, or a partially loaded route. Verify an authenticated signal and check for visible login errors.
Can Puppeteer fill a password manager’s field?
It can fill ordinary password inputs. Password-manager extensions and security policies may alter the page, so test against the actual authorized environment and do not expose credentials in logs.
Can I use ScreenshotNeo to submit a login form?
ScreenshotNeo is a screenshot and page-capture API. Use Puppeteer or the site’s supported authentication integration to establish access, then use ScreenshotNeo for authorized page captures with the supported request options.


