TechByteByByte

Part 18: Authentication

Test login flows and reuse authenticated browser state efficiently.

Authentication proves who a user is. Saved browser state acts like a reusable entry pass after login.

login → save proof → reuse proof safely → test

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

Every test in this series so far has logged in from scratch — filling the username and password fields, clicking Login, waiting for the redirect. That’s fine for a handful of tests. It becomes a genuine, measurable time cost once a suite has a hundred tests, most of which don’t actually care about testing login itself — they just need to already be logged in so they can test something else. This part solves that properly, and builds the conceptual foundation (sessions, cookies, tokens) needed to understand why the solution actually works.


Sessions, Cookies, and Tokens

Recall Part 0.3’s login walkthrough: the backend checks credentials against the database, then sends a response back. Here’s the piece that walkthrough deliberately deferred: HTTP, by its basic nature, is stateless — each request is independent, with no inherent memory of any previous request. Without some mechanism to bridge that gap, a server would have no way to know that the “add to cart” request arriving a moment after a successful login came from the very same, now-authenticated user.

A session is the general concept of the server (or the client) maintaining state across multiple requests, bridging that statelessness. There are two common mechanisms:

Cookies

— recall Part 1.4’s DevTools Application tab. When a login succeeds, the server can respond with an instruction for the browser to store a small piece of data (a cookie) — often a session identifier. The browser then automatically attaches that cookie to every subsequent request to the same domain, letting the server recognize “this request belongs to the same logged-in user” without the user re-entering credentials each time.

Tokens, and specifically JWT (JSON Web Token)

— a different, increasingly common approach, especially for APIs. After login, the server returns a token (a specially encoded string) directly in the response body, rather than as a cookie. The client is then responsible for storing it itself (commonly in Local Storage, from Part 1.4) and explicitly attaching it to future requests, typically as an Authorization header (recall Part 17’s header discussion): Authorization: Bearer <token>.

Authentication vs. authorization, a distinction worth being precise about, since the two get casually conflated constantly: authentication

answers “who are you?” — verifying identity, which is what the login process itself accomplishes. Authorization answers “what are you allowed to do?” — a separate question, answered after authentication, determining whether this specific, now-known user is permitted to perform a specific action (view an admin dashboard, delete another user’s data, and so on). A test verifying that a logged-in standard user can’t access an admin-only page is testing authorization, not authentication — a genuinely important distinction when describing what a test actually covers.


Storage State — Logging In Once, Reusing It Everywhere

Here’s the core idea this whole part has been building toward: if a login session is really just cookies and/or storage data sitting in the browser, then Playwright can capture that data once, after a real login, and inject it directly into future test runs — completely skipping the actual login UI steps every single time after the first.

Analogy: The VIP Concert Wristband Imagine attending a large music festival:

  • Logging in repeatedly (Wrong): At every checkpoint (VIP lounge, food truck, stage gate), you pull out your ID, verify your address, and wait for security to run a background check. You waste minutes at every single door.
  • Storage State (Wristband): You verify your ID once at the front gate (auth-setup). Security snaps a security wristband onto your wrist (the saved storage state JSON file). Now, at every subsequent gate (individual tests), you simply show the wristband and walk through instantly.

📊 Visual Flowchart: Multi-Role Authentication Pipeline

Here is how multiple test projects log in once per role and parallelize test execution:

graph TD
    subgraph SetupPhase ["Setup Phase (Run Once)"]
        LoginAdmin["Login as Admin"] --> SaveAdmin["Save state: admin.json"]
        LoginUser["Login as Standard User"] --> SaveUser["Save state: standard-user.json"]
    end

subgraph TestExecution ["Test Execution Phase (Parallel Workers)"]
        SaveAdmin --> ProjectAdmin["Project: admin-tests<br>(Injects admin.json)"]
        SaveUser --> ProjectUser["Project: standard-tests<br>(Injects standard-user.json)"]

ProjectAdmin --> RunAdmin["Run Admin Tests<br>(Starts logged in)"]
        ProjectUser --> RunUser["Run Standard Tests<br>(Starts logged in)"]
    end
// auth-setup.ts — a one-time setup script, not a regular test
import { test as setup, expect } from "@playwright/test";

const authFile = "playwright/.auth/standard-user.json";

setup("authenticate as standard user", async ({ page }) => {
  await page.goto("/");
  await page.getByPlaceholder("Username").fill("standard_user");
  await page.getByPlaceholder("Password").fill("secret_sauce");
  await page.getByRole("button", { name: "Login" }).click();
  await expect(page.getByText("Products")).toBeVisible();

  // Save the current browser context's cookies AND local storage to a file
  await page.context().storageState({ path: authFile });
});

This generates a JSON file containing exactly the cookies and storage data that resulted from a genuine, real login. Now, any test can start already in that authenticated state, simply by telling its browser context to load that saved state instead of starting empty:

// playwright.config.ts
projects: [
  {
    name: 'chromium-authenticated',
    use: {
      ...devices['Desktop Chrome'],
      storageState: 'playwright/.auth/standard-user.json',
    },
  },
],
// inventory.spec.ts
test("logged-in user can add item to cart", async ({ page }) => {
  await page.goto("/inventory.html"); // no login steps at all — already authenticated
  await expect(page.getByText("Products")).toBeVisible();
  // ... proceed directly to the actual test ...
});

Think carefully about what this actually buys you, beyond raw speed (though the speed genuinely matters at scale — a hundred tests each skipping several seconds of login UI interaction adds up to real, meaningful time saved).

It also means only one test in your entire suite is actually responsible for verifying login itself works — every other test that merely needs an authenticated user can rely on the saved state without redundantly re-testing login functionality it doesn’t actually care about.

This is a direct, practical instance of the testing-pyramid thinking from Part 0.6 — not duplicating the same verification unnecessarily across every single test that happens to touch a related area.

Global Setup

For a whole suite that always needs authentication, this login-and-save step is commonly run once automatically, before the rest of the suite, via global setup:

// playwright.config.ts
export default defineConfig({
  globalSetup: require.resolve("./global-setup.ts"),
  use: {
    storageState: "playwright/.auth/standard-user.json",
  },
});
// global-setup.ts
import { chromium, FullConfig } from "@playwright/test";

async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto("https://www.saucedemo.com");
  await page.getByPlaceholder("Username").fill("standard_user");
  await page.getByPlaceholder("Password").fill("secret_sauce");
  await page.getByRole("button", { name: "Login" }).click();

  await page
    .context()
    .storageState({ path: "playwright/.auth/standard-user.json" });
  await browser.close();
}

export default globalSetup;

This runs exactly once, before any test in the entire run begins — logging in a single time and saving the resulting state, which every project’s storageState setting then automatically reuses.

Multi-Role Authentication

Real applications rarely have just one kind of user. SauceDemo itself, deliberately, provides several distinct test accounts — standard_user, locked_out_user, problem_user, and others — each representing a genuinely different scenario worth testing separately. A real production application might similarly need admin, standard user, and guest roles tested independently, each with its own permissions and its own expected behavior.

The pattern extends naturally — simply save a separate storage state file per role:

setup("authenticate as admin", async ({ page }) => {
  // ... admin login steps ...
  await page.context().storageState({ path: "playwright/.auth/admin.json" });
});

setup("authenticate as standard user", async ({ page }) => {
  // ... standard user login steps ...
  await page
    .context()
    .storageState({ path: "playwright/.auth/standard-user.json" });
});
// playwright.config.ts
projects: [
  { name: 'admin-tests', use: { storageState: 'playwright/.auth/admin.json' } },
  { name: 'standard-user-tests', use: { storageState: 'playwright/.auth/standard-user.json' } },
],

This is a genuinely common, realistic interview scenario worth being ready to discuss out loud: how would you test that an admin can see a “Delete User” button but a standard user can’t? The answer is exactly this pattern — two separate projects (or two separate test files each explicitly loading a different saved storage state), each independently verifying the correct, role-appropriate behavior, rather than trying to awkwardly switch roles mid-test.


How It Works in a Real Test Run

Storage state serializes browser authentication data such as cookies and local storage so tests can begin authenticated without repeating a slow login UI. It is sensitive material: anyone holding a valid state file may be able to act as that user until the session expires.

A robust flow creates role-specific state in a setup project, keeps files out of source control, refreshes expired state, and gives parallel workers separate accounts when server-side actions would collide.

Interview Questions

Q: Why is HTTP described as “stateless,” and what problem does that create for something like staying logged in?

Ans: HTTP treats each request as independent, with no inherent memory of any previous request. Without an additional mechanism, a server would have no way to recognize that a request arriving moments after a successful login came from that same, now-authenticated user — each request would look equally anonymous. Cookies and tokens exist specifically to bridge this gap, letting state (like “this user is logged in”) persist across multiple, otherwise-independent requests.

Q: What’s the practical difference between how cookie-based sessions and token-based (JWT) sessions typically work?

Ans: With cookie-based sessions, the server instructs the browser to store a session identifier as a cookie, which the browser then automatically attaches to future requests to the same domain. With token-based sessions, the server returns a token directly in the response body after login, and the client is responsible for storing it itself (often in Local Storage) and explicitly attaching it to future requests, typically via an Authorization header.

Q: What is the difference between authentication and authorization?

Ans: Authentication answers “who are you?” — verifying identity, which is what a login process accomplishes. Authorization answers “what are you allowed to do?” — a separate question determining whether an already-identified user is permitted to perform a specific action. A test checking that a logged-in standard user is correctly blocked from an admin-only page is testing authorization, not authentication.

Q: What does storageState actually capture, and why does reusing it save meaningful time across a large test suite?

Ans: It captures a browser context’s cookies and storage data at a specific point in time — typically right after a genuine, successful login. Reusing a saved storage state lets subsequent tests start already authenticated, skipping the actual login UI interaction entirely, which meaningfully reduces total execution time across a suite where most tests need to be logged in but aren’t actually testing the login process itself.

Q: Why is it good practice to have only one (or very few) tests actually exercise the real login flow, while most other tests rely on a saved storage state instead?

Ans: Repeatedly re-testing login functionality in every single test that merely needs an authenticated user is redundant — it doesn’t add real additional verification value once login itself is already covered by a dedicated test, and it costs real, unnecessary execution time across the whole suite. This mirrors testing-pyramid thinking: verify a given piece of functionality thoroughly once, in the test actually responsible for it, and let other tests depend on that verified state rather than re-proving it themselves.

Q: How would you design a test setup to verify that an admin user can see a “Delete User” button but a standard user cannot?

Ans: I’d maintain two separate saved storage state files, one captured from a genuine admin login and one from a genuine standard user login, and configure two separate test projects (or explicitly load the appropriate storage state per test file) so that admin-specific tests run already authenticated as an admin, and standard-user tests run already authenticated as a standard user — each independently asserting the correct, role-appropriate visibility of the button, rather than attempting to switch roles within a single test.


Exercises — Part 18

Understand: Explain, in your own words, why HTTP’s statelessness specifically requires a mechanism like cookies or tokens to support something as basic as staying logged in across multiple page loads.

Simple Practice: Write a one-time setup script that logs into SauceDemo as standard_user and saves the resulting storage state to a file, then write a separate test that loads that saved state and navigates directly to the inventory page without performing any login steps itself.

Real-World Scenario: SauceDemo provides a locked_out_user account that’s intentionally denied login. Write a dedicated test — separate from your storage-state-reusing tests — that actually exercises the real login UI with this account and asserts the correct error message appears, explaining in a comment why this specific test needs to perform a real login rather than relying on a saved storage state.

Challenge: Research the difference between storing a JWT in Local Storage versus in a cookie, specifically regarding a security concern called XSS (Cross-Site Scripting) vulnerability exposure. Write a short summary, in your own words, of which storage approach is generally considered more resistant to this specific risk, and why.


Next: Part 19 — Network Handling

— intercepting, mocking, and modifying network requests and responses with page.route.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed