Designing and Building Concurrency-Safe Authentication for Playwright Test Automation
- Anuradha Agarwal
- 4 days ago
- 7 min read
Updated: 1 day ago
Joining partway through?
This is Post 4 of Phase 3: Enterprise Scalability, part of my Playwright Automation Testing Udemy course.
Post 1 - Scale Test Automation: lays out what this series is about.
Post 2 - Getting Your Playwright Framework Running: gets the Playwright framework running on your own machine.
Post 3 - Running in parallel for the first time is where a single shared login collides with itself the moment you turn workers up from one to four- the exact problem this post exists to solve
Post 3 ended with a diagnosis: one shared login, four workers, and a guaranteed collision. Not a bug in the tests. A structural problem in the framework itself.
This post is where we actually fix it, not by patching around the symptom, but by designing the piece of the framework that was never built for concurrency in the first place: authentication.
Why "just add more accounts" isn't the whole answer

Right now, helpers/auth.setup.ts authenticates once, using a single
DEMO_USER / DEMO_PASS pair from .env, and saves the result to auth/storageState.json.
fixtures / baseFixtures.ts then loads that one file into every browser context, regardless of which worker is asking. This is the exact handoff point: the course builds everything up to here from scratch, step by step, and this post picks up from this file state onward.
The obvious fix sounds simple: get three more accounts, and branch inside baseFixtures.ts based on which worker is asking, since testInfo.workerIndex is already sitting right there in scope.
Here's what that looks like:
const authFile = testInfo.workerIndex === 0
? path.join(__dirname, '..', 'auth', 'user1-storageState.json')
: path.join(__dirname, '..', 'auth', 'user2-storageState.json')
This works today, with exactly two accounts. It's short. It ships.
Now watch what happens the moment a third account joins the pool:
const authFile = testInfo.workerIndex === 0
? path.join(__dirname, '..', 'auth', 'user1-storageState.json')
: testInfo.workerIndex === 1
? path.join(__dirname, '..', 'auth', 'user2-storageState.json')
: path.join(__dirname, '..', 'auth', 'user3-storageState.json')
And a fourth:
const authFile = testInfo.workerIndex === 0
? path.join(__dirname, '..', 'auth', 'user1-storageState.json')
: testInfo.workerIndex === 1
? path.join(__dirname, '..', 'auth', 'user2-storageState.json')
: testInfo.workerIndex === 2
? path.join(__dirname, '..', 'auth', 'user3-storageState.json')
: path.join(__dirname, '..', 'auth', 'user4-storageState.json')
Every new account means editing this exact block again, inside the same fixture that's also responsible for creating the browser context, recording video, and handling teardown on failure. The account count stopped being a configuration value the moment it got written directly into a conditional living alongside setup logic that has nothing to do with identity.
The question that actually matters isn't "does this work right now." It's "what does this cost the next time something changes, and where does that cost land."
This is exactly where the framework stands by the end of the course, built there step by step from scratch. This post picks up from that file state onward, if you'd rather build it here than read it.
Think of it like a shift, not a task
Here's the mental model worth holding onto for the rest of this post.
A worker is like a staff member working a shift. Each test is one task that worker does during that shift, one at a time, the next task only starting once the last one is finished. A staff member doesn't get issued a new employee badge every time they start a new task, that would be pointless. They get one badge for the whole shift, and carry it through every task, one after another, until the shift ends.
That's the fix. Not "which account for which test," but "which account for which worker, decided once, kept for the whole shift."
Three pieces make this work, each with one job.


That's the fix. Not "which account for which test," but "which account for which worker, decided once, kept for the whole shift."
Three pieces make this work, each with one job.

The roster: IdentityProvider

Before anyone gets a badge, there needs to be a list of who's allowed to work at all.
Create this file: auth/IdentityProvider.ts, a new file inside the auth folder you already have.
export interface Identity {
id: string;
email: string;
password: string;
}
export class IdentityProvider {
load(): Identity[] {
return [
{ id: 'user1', email: process.env.TEST_USER_1_EMAIL!, password: process.env.TEST_USER_1_PASSWORD! },
{ id: 'user2', email: process.env.TEST_USER_2_EMAIL!, password: process.env.TEST_USER_2_PASSWORD! },
];
}
}That's all it does. It doesn't hand out badges. It doesn't decide who works which shift. It's the roster, nothing more.
I'm building this exact piece, step by step, as a new section of the course right now, so if you'd rather watch it get built than read it, that's coming.
Orientation day: AuthenticationManager, run by auth.setup.ts
Before any shift starts, every name on the roster needs to be processed once: logged in, verified, given a badge that means something - a saved session file.
AuthenticationManager is the person doing that processing, one identity at a time.

Create this file: auth/AuthenticationManager.ts, alongside IdentityProvider.ts in the same auth folder.
import { Browser } from '@playwright/test';
import path from 'path';
import { LoginPage } from '../pages/LoginPage';
import { Identity } from './IdentityProvider';
export class AuthenticationManager {
constructor(private browser: Browser, private baseUrl: string) {}
async authenticateAndSave(identity: Identity): Promise<string> {
const context = await this.browser.newContext();
const page = await context.newPage();
const loginPage = new LoginPage(page);
await loginPage.navigate(this.baseUrl);
await loginPage.login(identity.email, identity.password);
await loginPage.verifyLoggedIn();
const filePath = path.join(__dirname, 'storage', `${identity.id}.json`);
await context.storageState({ path: filePath });
await context.close();
return filePath;
}
}Notice it uses your existing LoginPage exactly as it already worked. It just calls it once per identity instead of once, total. AuthenticationManager itself is new, another piece of the same course section currently in production.
Your existing auth.setup.ts becomes orientation day itself, walking the whole roster through AuthenticationManager before any shift begins. This is code you already have from the course, evolved, not replaced:
Modify this file: helpers/auth.setup.ts, the one already sitting in your helpers folder.
import { test as setup } from '@playwright/test'
import { IdentityProvider } from '../auth/IdentityProvider';
import { AuthenticationManager } from '../auth/AuthenticationManager';
const baseUrl = process.env.BASE_URL!;
setup('authenticate all identities', async ({ browser }) => {
const authManager = new AuthenticationManager(browser, baseUrl);
const identities = new IdentityProvider().load();
for (const identity of identities) {
const filePath = await authManager.authenticateAndSave(identity);
console.log(`Auth state saved for ${identity.id} to ${filePath}`);
}
});
Same idea as before, just looped. End result: user1.json and user2.json both exist, both ready, before a single test runs. They land in a new subfolder, auth/storage/, since AuthenticationManager writes there. Create that folder now if it doesn't exist yet, and add it to .gitignore alongside the old storageState.json line, these are session files, never committed.
The shift manager: WorkerResolver
Badges exist now. Someone still has to decide, for each worker, which badge they carry for their entire shift.

Create this file: auth/WorkerResolver.ts, the third and last new file in the auth folder.
import path from 'path';
import { Identity, IdentityProvider } from './IdentityProvider';
export class WorkerResolver {
private identities: Identity[];
constructor(identityProvider: IdentityProvider) {
this.identities = identityProvider.load();
}
resolve(workerIndex: number): { identity: Identity; storagePath: string } {
const identity = this.identities[workerIndex % this.identities.length];
const storagePath = path.join(__dirname, 'storage', `${identity.id}.json`);
return { identity, storagePath };
}
}Worker 0 asks once, gets user1.json, and keeps it for the entire shift. Worker 1 asks once, gets user2.json, and keeps it for the entire shift.
WorkerResolver, like IdentityProvider and AuthenticationManager, is new, part of the same section being built in the course right now.

resolve() returns an identity and a file path, but it doesn't call itself and has no way to reach the browser on its own. Something has to actually call it, at the right moment, once per worker, and hand what it returns to Playwright. That's a fixture's job, and it's why the file you're about to see change is baseFixtures.ts, not WorkerResolver.ts again. Two things worth understanding first, though, before that code makes sense.
How the math can quietly break
The rule deciding which account a worker uses is workerIndex % identityPool.length. Worker 0 gets account 1, worker 1 gets account 2, and worker 2 wraps back to account 1.
That wraparound is where it gets risky. With two accounts, this rule only stays safe up to two workers. Add a third worker, and worker 2 lands back on the same account as worker 0, both running at the same time, in the middle of their shifts.
That's the exact bug from the last post coming back, just hidden inside a math shortcut instead of an obvious shared login.
Simple math, serious consequence. Whatever your account pool ends up being, your worker count can never go past it.

Why the roster gets read twice, not once
You've already got a setup project in playwright.config.ts, the one that runs helpers/auth.setup.ts before anything else, with chromium listed as dependent on it. That dependency matters for a reason worth understanding.
Setup and your test workers are separate programs running on your machine, not one program sharing memory. Setup starts, runs auth.setup.ts once, finishes, and shuts down completely. Then, separately, each worker starts up on its own, sometimes seconds later, with no memory of what setup did or what it loaded.

So there's no way to load the roster once inside setup and hand it down to a worker later. By the time a worker asks "which account am I," setup is already gone. That's why IdentityProvider gets called independently, once inside setup, and once inside every worker that asks. Not wasted work. The only way this can actually work, given that setup and workers are separate processes with nothing shared between them.
What's next
Three classes exist now, each doing exactly one job, and the design is proven safe up to the size of the account pool. What doesn't exist yet is anything that actually calls
WorkerResolver or hands its answer to a real browser. That's the next post: worker scope explained properly, the fixture built, and this whole concurrency problem, opened back in Post 3, closed for good.
This is Post 4 of the Phase 3: Enterprise Scalability series. Post 1 · Post 2 · Post 3 · Post 4 Next: Post 5, building the fixture and completing concurrency · Repo link · Course link




Comments