Authentication Strategies — Skip the Login Line
If your test suite has 200 tests and takes 3 seconds to log in for each, you're wasting 10 minutes every run. Learn how to authenticate once, save the state, and reuse it instantly across your entire suite.
📖 The Story: The Login Bottleneck
Marcus was proud of his 500-test Playwright suite. But as the app grew, the CI run time crept up. First it was 15 minutes. Then 25 minutes. Soon, it hit 45 minutes.
Developers were complaining. Pull requests were stacking up. Marcus investigated and found the culprit: every single test started by navigating to /login, typing an email and password, waiting for the dashboard to load, and then doing the actual test.
That login process took 4 seconds per test. 500 tests × 4 seconds = 33 minutes wasted purely on logging in. Worse, the auth server started rate-limiting his CI server because it was getting hit with 500 logins every hour.
Marcus implemented a globalSetup script. It logged in exactly once, saved the cookies and localStorage to a file, and injected that state into every test. The suite time plummeted from 45 minutes to 12 minutes. The rate-limiting alerts stopped instantly.
Logging in is a prerequisite, not a test. Stop testing it 500 times.
🎯 Why Bypass Login?
Massive Speed Gains
Cut 3-5 seconds off every test. Transform 40-minute suites into 10-minute suites.
Reduce Server Load
Stop hammering your auth database. One login per run instead of hundreds.
Test Isolation
Focus tests purely on the feature under test. Login logic shouldn't fail a cart checkout test.
Rate Limit Safety
Avoid triggering CI/CD firewalls and auth provider rate limits (e.g., Okta, Auth0).
🌍 Real World Example
Imagine testing a "View Profile" page. In a naive setup, the test logs in, navigates to profile, and checks the name. If the login server has a hiccup, the test fails—even though the Profile page was perfectly fine.
By saving the authenticated state, Playwright opens a browser, loads the saved cookies, and navigates directly to the Profile page. The test is now purely testing the Profile page, completely isolated from the Login system.
🧒 Explain Like I'm 10
Imagine going to a theme park. Every time you want to ride a roller coaster, the staff asks for your ID, checks your height, and makes you sign a waiver. It takes 5 minutes. If you ride 10 rides, you waste 50 minutes standing in line just to prove who you are.
Instead, the theme park gives you a brightly colored wristband when you enter. Now, the roller coaster staff just checks for the wristband. Boom. You're on the ride in 2 seconds.
storageState is that wristband. You get it once, and Playwright flashes it at the website every time it opens a new page.
🎓 Professional Explanation
Authentication state in modern web apps is primarily stored in HTTP-only Cookies and Web Storage (localStorage/sessionStorage). Playwright's storageState API allows you to serialize this data into a JSON file.
By placing this logic in a globalSetup function, Playwright executes the login exactly once per test run (either via UI or API). The resulting JSON file is then passed to the use.storageState configuration option. When Playwright spawns a new BrowserContext for a test, it hydrates the context with these cookies and storage items before any page loads, instantly authenticating the user.
📊 UI vs Setup Auth
The Shift from Per-Test Login to Global Setup
Naive Approach (Every Test)
Setup Approach (Global)
💾 What is storageState?
When you call await context.storageState({ path: 'auth.json' }), Playwright generates a JSON file that looks like this:
{
"cookies": [
{
"name": "session_id",
"value": "abc123def456",
"domain": "app.com",
"path": "/",
"expires": 1700000000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://app.com",
"localStorage": [
{ "name": "authToken", "value": "jwt.token.here" }
]
}
]
}
🌍 Strategy 1: UI Authentication in globalSetup
Use this if your app requires complex UI interactions to log in (e.g., SAML SSO redirects that are hard to hit via API).
import { chromium, FullConfig } from '@playwright/test'; export default async function globalSetup(config: FullConfig) { const browser = await chromium.launch(); const page = await browser.newPage(); // Perform UI login await page.goto('https://app.com/login'); await page.fill('#email', process.env.TEST_USER!); await page.fill('#password', process.env.TEST_PASS!); await page.click('button[type=submit]'); await page.waitForURL('**/dashboard'); // Save the state to a file await page.context().storageState({ path: 'auth-state.json' }); await browser.close(); }
⚡ Strategy 2: API Authentication in globalSetup (Recommended)
This is drastically faster. Skip the browser entirely and just hit the login API.
import { request } from '@playwright/test'; export default async function globalSetup() { // Create a request context (no browser!) const context = await request.newContext({ baseURL: 'https://app.com' }); // Hit the login API endpoint const response = await context.post('/api/auth/login', { data: { email: process.env.TEST_USER, password: process.env.TEST_PASS } }); // Save the cookie jar from the API context to a file await context.storageState({ path: 'auth-state.json' }); await context.dispose(); }
Wiring it up: In playwright.config.ts, point to this file: globalSetup: require.resolve('./global-setup'), and in your use block, add storageState: 'auth-state.json'.
🎭 Handling Multiple Roles
What if you need an Admin and a standard User? Create multiple setup files or save multiple states.
export default defineConfig({ projects: [ { name: 'Admin Setup', testMatch: /admin-setup\.ts/, }, { name: 'Admin Tests', dependencies: ['Admin Setup'], use: { storageState: 'admin-state.json', }, }, { name: 'User Tests', use: { storageState: 'user-state.json', }, } ] });
⚠️ Common Mistakes
Mistake 1: Logging in inside beforeEach
// ❌ BAD: Runs login before EVERY test test.beforeEach(async ({ page }) => { await page.goto('/login'); await page.fill('#email', 'user@test.com'); // ... }); // ✅ GOOD: Use storageState in config. beforeEach just navigates to the page. test.beforeEach(async ({ page }) => { await page.goto('/dashboard'); });
Mistake 2: Committing Auth Tokens to Git
Never commit auth-state.json to your repository. Tokens expire, environments change, and it's a security risk. Add it to .gitignore and generate it dynamically in CI.
✅ Best Practices
- Use
globalSetupfor one-time logins. It runs before all tests. - Prefer API logins over UI logins for setup. It's faster and more resilient.
- Add
auth-state.jsonto.gitignore. - Handle session expiration. If your token expires in 1 hour, and your suite takes 2 hours, your tests will fail halfway through. Refresh the state or use long-lived test tokens.
- Use separate accounts for setup vs tests. If the setup account gets locked, all tests fail.
🔒 Security Notes
- Never hardcode passwords. Always use environment variables (
process.env.TEST_PASS) injected by CI/CD. - Use dedicated QA accounts. Do not use real user accounts or admin production accounts for testing.
- Secure the storage state file. Treat
auth-state.jsonlike a password. In CI, wipe it after the run.
🏗️ Senior Engineer Deep Dive
How Playwright Injects State
When a BrowserContext is created with a storageState file, Playwright does not navigate to the app to set cookies. Instead, it uses the Chrome DevTools Protocol (CDP) Network.setCookie command to inject cookies directly into the browser's network stack before any page is opened. For localStorage, it injects scripts via Page.addScriptToEvaluateOnNewDocument. This is why the state is available instantly without a flash of the login screen.
Handling 2FA / MFA
If your app enforces 2FA, UI login in globalSetup becomes tricky. The enterprise pattern is to have a bypass endpoint in your backend specifically for QA environments that accepts a master QA token and returns a valid session, bypassing the 2FA requirement entirely.
💼 Interview Questions
Show Answer
storageState option in the Playwright config. You log in once, save the cookies and localStorage to a JSON file, and Playwright automatically loads that state into the browser context for every test.globalSetup in Playwright?Show Answer
globalSetup is a function that runs once before the entire test suite begins. It is primarily used for authentication setup, starting databases, or seeding test data, ensuring these heavy operations don't slow down individual tests.storageState into a new browser context?Show Answer
Network.setCookie to inject cookies at the network layer before any navigation occurs. For localStorage, it uses Page.addScriptToEvaluateOnNewDocument to execute scripts that populate storage before the page's own scripts run.Show Answer
beforeEach hooks. I would create a globalSetup script that performs a single API login, saving the session to auth.json. Then, I would configure the projects in playwright.config.ts to use storageState: 'auth.json'. This drops the login time from 40 minutes to roughly 0.5 seconds.🏋️ Practical Exercise
Implement Global API Auth
- Create a file named
global-setup.ts. - Using
request.newContext(), make a POST request to a test API (e.g.,reqres.in/api/login). - Save the storage state to
auth.json. - In
playwright.config.ts, setglobalSetupto your file. - Set
use: { storageState: 'auth.json' }. - Write a test that navigates directly to a protected page. Verify it loads without logging in.
- Add
auth.jsonto your.gitignore.
🚀 Mini Project
🏗️ Dual Role Authentication
Build a suite that supports two roles: admin and user.
- Create two setup files:
admin-setup.tsanduser-setup.ts. - Save their respective states to
admin.jsonanduser.json. - Configure two projects in
playwright.config.ts. - Write a test for the Admin project that verifies access to an admin-only settings page.
- Write a test for the User project that verifies the settings page is inaccessible (redirects or shows 403).
📝 Quiz
Test your understanding. Click an option to check your answer.
storageState API save?storageState serializes the browser's cookies and origins' local/session storage into a JSON file.beforeEach hooktest() blockglobalSetup function in the configafterAll hookglobalSetup runs exactly once before the test suite starts, making it perfect for saving storageState.auth.json to all tests in a project?page.loadAuth('auth.json')use: { storageState: 'auth.json' } in playwright.config.tstest.use({ auth: 'auth.json' })test()storageState in the use block of the config applies it automatically to every BrowserContext.auth.json?.gitignore and generate it dynamically in CIstorageState filepage.changeUser()storageState JSON file.storageState technically?document.cookieNetwork.setCookiepage.context().storageState({ path: 'auth.json' }) inside a test?auth.jsonbeforeEach?beforeEach runs before every single test. Repeatedly logging in via UI adds seconds to every test, ballooning suite execution time.❓ FAQ
Q: Can I use storageState for mobile web emulation?
A: Yes. Mobile contexts use the same cookie/localStorage mechanisms. The same auth.json file works for desktop and mobile projects.
Q: How do I handle applications that use IndexedDB for auth?
A: storageState does not natively save IndexedDB. You would need to use page.evaluate() to read and write IndexedDB manually during setup.
Q: Does storageState work across different domains?
A: Yes, the JSON file contains an array of origins. If your app spans app.com and auth.com, Playwright will inject the respective cookies and storage into both domains.
📦 Summary
🎯 Key Takeaways
- Stop logging in via UI for every test. It destroys suite performance.
- Use
globalSetupto authenticate exactly once per test run. - API login is the fastest setup method. UI login is acceptable if SSO is complex.
storageStatesaves cookies and localStorage to a JSON file.- Wire the JSON file into your config via
use: { storageState: '...' }. - Never commit auth files to Git. Generate them dynamically in CI.
