Skip to content

Smart Authentication Token Management in Playwright

Authentication is often the bottleneck in API test suites. Every test that needs an authenticated session typically starts by logging in, getting a token, and then making API calls. When you have hundreds of tests, those repeated login operations add up to significant time and resource waste.

This guide shows you how to build a smart token management system that caches authentication tokens, handles expiration gracefully, and eliminates redundant login operations—patterns proven in production environments testing enterprise applications.

Consider a typical API test suite without token management:

// Every single test does this
test('get user profile', async ({ request }) => {
// Login again (5-10 seconds)
const loginResponse = await request.post('/auth/login', {
data: { email: 'test@example.com', password: 'password123' }
});
const token = loginResponse.headers()['authorization'];
// Now make the actual test request
const response = await request.get('/api/profile', {
headers: { Authorization: token }
});
expect(response.status()).toBe(200);
});
test('get user settings', async ({ request }) => {
// Login AGAIN (another 5-10 seconds wasted)
const loginResponse = await request.post('/auth/login', {
data: { email: 'test@example.com', password: 'password123' }
});
const token = loginResponse.headers()['authorization'];
// Actual test
const response = await request.get('/api/settings', {
headers: { Authorization: token }
});
expect(response.status()).toBe(200);
});
// Repeat this pattern 100+ times...

The Cost:

  • 100 tests × 7 seconds per login = 11.6 minutes wasted on logins alone
  • Unnecessary server load
  • Increased risk of rate limiting
  • Flaky tests from network issues during login
  • Slower feedback loops in CI/CD

A production-ready token management system provides:

  1. Caching: Store tokens in memory and reuse them
  2. Persistence: Save tokens to disk to survive test worker restarts
  3. Expiration Handling: Automatically refresh when tokens expire
  4. Multiple Contexts: Support different auth tokens for different services
  5. Concurrency Safety: Handle parallel test execution

Let’s build this step by step.


Our token management system has three layers:

┌─────────────────────────────────────────────┐
│ Test Layer │
│ (Tests just call getAuthToken()) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Token Manager (Smart Cache) │
│ • Check memory cache │
│ • Check file cache │
│ • Validate expiration │
│ • Trigger login if needed │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ Authentication Layer │
│ • Perform actual login │
│ • Extract token from response │
│ • Store token with timestamp │
└─────────────────────────────────────────────┘

Key insight: Tests never directly perform login. They just ask for a token, and the manager handles the complexity.


Create utilities/auth/token-manager.js:

const fs = require('fs').promises;
/**
* Configuration
*/
const TOKEN_FILE_PATH = 'auth-token.txt';
const TOKEN_EXPIRATION_MS = 60 * 60 * 1000; // 1 hour
/**
* In-memory cache
*/
let cachedToken = null;
let tokenTimestamp = 0;
/**
* Main function: Get auth token (cached or fresh)
*
* @param {Page} page - Playwright page for login if needed
* @param {string} email - User email
* @param {string} password - User password
* @returns {Promise<string>} Authentication token
*/
async function getAuthToken(page, email, password) {
console.log('🔑 Requesting authentication token...');
const currentTime = Date.now();
// Check 1: Memory cache
if (cachedToken && !isTokenExpired(tokenTimestamp, currentTime)) {
console.log('✅ Using cached token from memory');
return cachedToken;
}
// Check 2: File cache
const fileToken = await readTokenFromFile();
if (fileToken && !isTokenExpired(fileToken.timestamp, currentTime)) {
console.log('✅ Using cached token from file');
cachedToken = fileToken.token;
tokenTimestamp = fileToken.timestamp;
return cachedToken;
}
// Check 3: Need fresh login
console.log('🔄 Token expired or not found - performing login');
cachedToken = await performLogin(page, email, password);
tokenTimestamp = currentTime;
// Persist to file for next time
await saveTokenToFile(cachedToken, tokenTimestamp);
console.log('✅ Fresh token obtained and cached');
return cachedToken;
}
/**
* Check if token is expired
*/
function isTokenExpired(timestamp, currentTime) {
return (currentTime - timestamp) >= TOKEN_EXPIRATION_MS;
}
module.exports = { getAuthToken };

What this gives you:

  • ✅ Memory cache for same test worker
  • ✅ File cache for different test workers
  • ✅ Automatic expiration handling
  • ✅ Clear logging for debugging

Add file operations to save and retrieve tokens:

/**
* Save token to file with timestamp
*/
async function saveTokenToFile(token, timestamp) {
try {
const data = JSON.stringify({ token, timestamp });
await fs.writeFile(TOKEN_FILE_PATH, data, 'utf8');
console.log('💾 Token saved to file');
} catch (error) {
console.error('⚠️ Failed to save token to file:', error.message);
// Don't throw - file caching is optional optimization
}
}
/**
* Read token from file
*/
async function readTokenFromFile() {
try {
const fileStats = await fs.stat(TOKEN_FILE_PATH);
const data = await fs.readFile(TOKEN_FILE_PATH, 'utf8');
const { token, timestamp } = JSON.parse(data);
console.log('📂 Token file found (age: ${Math.round((Date.now() - timestamp) / 1000)}s)');
return { token, timestamp };
} catch (error) {
// File doesn't exist or is corrupted - that's fine
console.log('📂 No valid token file found');
return null;
}
}

Why file persistence matters:

  • Playwright runs tests in parallel workers
  • Each worker starts fresh (new process)
  • File cache lets workers share tokens
  • Survives test suite restarts

Add to .gitignore:

auth-token.txt
*.token.txt

The actual authentication depends on your application. Here are common patterns:

/**
* Perform login via API and extract token
*/
async function performLogin(page, email, password) {
const context = page.context();
// Make login request
const response = await context.request.post('/api/auth/login', {
data: { email, password }
});
if (response.status() !== 200) {
throw new Error(`Login failed with status ${response.status()}`);
}
// Extract token from response
const body = await response.json();
const token = body.token || body.access_token;
if (!token) {
throw new Error('No token found in login response');
}
return `Bearer ${token}`;
}

Pattern B: Browser-Based Login (OAuth, SSO)

Section titled “Pattern B: Browser-Based Login (OAuth, SSO)”
/**
* Perform browser login and intercept token from network request
*/
async function performLogin(page, email, password) {
// Navigate to login page
await page.goto('/login');
// Fill login form
await page.fill('[name="email"]', email);
await page.fill('[name="password"]', password);
// Set up token interception BEFORE clicking login
const tokenPromise = new Promise((resolve) => {
page.on('request', (request) => {
// Intercept the first API request after login
if (request.url().includes('/api/')) {
const authHeader = request.headers()['authorization'];
if (authHeader) {
resolve(authHeader);
}
}
});
});
// Click login button
await page.click('button[type="submit"]');
// Wait for dashboard to load (confirms login success)
await page.waitForURL('**/dashboard');
// Get intercepted token
const token = await tokenPromise;
if (!token) {
throw new Error('Failed to intercept auth token');
}
return token;
}

Why browser-based login?

  • Required for OAuth/SAML/SSO flows
  • Handles MFA prompts
  • Works with complex authentication
  • Can extract tokens from cookies or network traffic

Now tests become beautifully simple:

tests/api/user-profile.spec.js
const { test, expect } = require('@playwright/test');
const { getAuthToken } = require('../../utilities/auth/token-manager');
const TEST_USER = {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD
};
test.describe('User Profile API', () => {
let authToken;
test.beforeEach(async ({ page }) => {
// Get token - cached if available
authToken = await getAuthToken(page, TEST_USER.email, TEST_USER.password);
});
test('should get user profile', async ({ request }) => {
const response = await request.get('/api/profile', {
headers: { Authorization: authToken }
});
expect(response.status()).toBe(200);
const profile = await response.json();
expect(profile.email).toBe(TEST_USER.email);
});
test('should get user settings', async ({ request }) => {
const response = await request.get('/api/settings', {
headers: { Authorization: authToken }
});
expect(response.status()).toBe(200);
});
test('should update user preferences', async ({ request }) => {
const response = await request.put('/api/preferences', {
headers: { Authorization: authToken },
data: { theme: 'dark', language: 'en' }
});
expect(response.status()).toBe(200);
});
});

What changed:

  • ✅ Login happens once per test file (or less with caching)
  • ✅ First test in suite logs in (~7s)
  • ✅ Remaining tests use cached token (~0.1s)
  • ✅ All tests remain independent (can run in any order)

Pattern 1: Multiple Authentication Contexts

Section titled “Pattern 1: Multiple Authentication Contexts”

Many applications need different tokens for different services:

// Separate tokens for different services
const tokenCaches = new Map();
async function getAuthToken(page, email, password, context = 'default') {
if (!tokenCaches.has(context)) {
tokenCaches.set(context, {
token: null,
timestamp: 0,
filePath: `auth-token-${context}.txt`
});
}
const cache = tokenCaches.get(context);
const currentTime = Date.now();
// Check cache with context-specific file
if (cache.token && !isTokenExpired(cache.timestamp, currentTime)) {
return cache.token;
}
// Read from context-specific file
const fileToken = await readTokenFromFile(cache.filePath);
if (fileToken && !isTokenExpired(fileToken.timestamp, currentTime)) {
cache.token = fileToken.token;
cache.timestamp = fileToken.timestamp;
return cache.token;
}
// Perform fresh login
cache.token = await performLogin(page, email, password);
cache.timestamp = currentTime;
await saveTokenToFile(cache.token, cache.timestamp, cache.filePath);
return cache.token;
}
// Usage
const appToken = await getAuthToken(page, email, password, 'app');
const adminToken = await getAuthToken(page, adminEmail, adminPassword, 'admin');
const dbToken = await getAuthToken(page, email, password, 'database');

config/auth.config.js
module.exports = {
default: {
expirationMs: 60 * 60 * 1000, // 1 hour
filePath: 'auth-token.txt'
},
shortLived: {
expirationMs: 5 * 60 * 1000, // 5 minutes
filePath: 'auth-token-short.txt'
},
longLived: {
expirationMs: 24 * 60 * 60 * 1000, // 24 hours
filePath: 'auth-token-long.txt'
}
};
// Usage
const config = require('./config/auth.config');
const token = await getAuthToken(page, email, password, 'default');

Pattern 3: Token Refresh Before Expiration

Section titled “Pattern 3: Token Refresh Before Expiration”

Proactively refresh tokens before they expire:

const REFRESH_BUFFER_MS = 5 * 60 * 1000; // Refresh 5 min before expiry
function shouldRefreshToken(timestamp, currentTime, expirationMs) {
const age = currentTime - timestamp;
const timeUntilExpiry = expirationMs - age;
return timeUntilExpiry < REFRESH_BUFFER_MS;
}
async function getAuthToken(page, email, password) {
const currentTime = Date.now();
if (cachedToken && !isTokenExpired(tokenTimestamp, currentTime)) {
// Token valid but close to expiring?
if (shouldRefreshToken(tokenTimestamp, currentTime, TOKEN_EXPIRATION_MS)) {
console.log('🔄 Token expiring soon - refreshing proactively');
cachedToken = await performLogin(page, email, password);
tokenTimestamp = currentTime;
await saveTokenToFile(cachedToken, tokenTimestamp);
}
return cachedToken;
}
// ... rest of logic
}

Pattern 4: Parallel Test Safety with Locks

Section titled “Pattern 4: Parallel Test Safety with Locks”

Prevent multiple workers from logging in simultaneously:

const fs = require('fs').promises;
const path = require('path');
const LOCK_FILE = 'auth-token.lock';
const LOCK_TIMEOUT_MS = 30000; // 30 seconds
async function acquireLock() {
const lockStart = Date.now();
while (true) {
try {
// Try to create lock file (fails if exists)
await fs.writeFile(LOCK_FILE, String(Date.now()), { flag: 'wx' });
console.log('🔒 Lock acquired');
return;
} catch (error) {
// Lock file exists - another worker is logging in
try {
const lockContent = await fs.readFile(LOCK_FILE, 'utf8');
const lockTime = parseInt(lockContent);
// Check if lock is stale
if (Date.now() - lockTime > LOCK_TIMEOUT_MS) {
console.log('⚠️ Stale lock detected - removing');
await fs.unlink(LOCK_FILE);
continue;
}
} catch (readError) {
// Lock file disappeared - try again
continue;
}
// Check total wait time
if (Date.now() - lockStart > LOCK_TIMEOUT_MS) {
throw new Error('Timeout waiting for auth lock');
}
// Wait and retry
await new Promise(resolve => setTimeout(resolve, 500));
}
}
}
async function releaseLock() {
try {
await fs.unlink(LOCK_FILE);
console.log('🔓 Lock released');
} catch (error) {
// Lock file already gone - that's fine
}
}
async function getAuthToken(page, email, password) {
// ... check memory cache first ...
// Need fresh token - acquire lock
await acquireLock();
try {
// Re-check file cache after acquiring lock
// (another worker might have just logged in)
const fileToken = await readTokenFromFile();
if (fileToken && !isTokenExpired(fileToken.timestamp, Date.now())) {
console.log('✅ Another worker logged in - using their token');
cachedToken = fileToken.token;
tokenTimestamp = fileToken.timestamp;
return cachedToken;
}
// Still need login - proceed
cachedToken = await performLogin(page, email, password);
tokenTimestamp = Date.now();
await saveTokenToFile(cachedToken, tokenTimestamp);
return cachedToken;
} finally {
// Always release lock
await releaseLock();
}
}

Why locking matters:

  • Multiple workers start simultaneously in CI
  • Without locking: 10 parallel logins (server overload)
  • With locking: 1 login, 9 workers wait and reuse token

Real-world results from a test suite with 150 API tests:

Total time: 18m 32s
Login operations: 150
Average login time: 7.2s
Time spent logging in: 18m 0s (97% of total time!)
Actual test time: 32s
Total time: 1m 45s
Login operations: 3 (one per test worker)
Average login time: 7.2s
Time spent logging in: 21.6s (20% of total time)
Actual test time: 1m 23s
Speed improvement: 10.5x faster 🚀

config/auth.js
const config = {
development: {
expirationMs: 24 * 60 * 60 * 1000, // 24 hours (convenience)
filePath: '.auth-token-dev.txt'
},
ci: {
expirationMs: 30 * 60 * 1000, // 30 minutes (security)
filePath: '.auth-token-ci.txt'
},
production: {
expirationMs: 10 * 60 * 1000, // 10 minutes (strict)
filePath: '.auth-token-prod.txt'
}
};
const env = process.env.NODE_ENV || 'development';
module.exports = config[env];
// For sensitive environments, encrypt tokens at rest
const crypto = require('crypto');
const ENCRYPTION_KEY = process.env.TOKEN_ENCRYPTION_KEY;
function encryptToken(token) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
let encrypted = cipher.update(token, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
function decryptToken(encrypted) {
const parts = encrypted.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encryptedText = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
async function getAuthToken(page, email, password) {
try {
// Try cache first
const cachedToken = await tryGetCachedToken();
if (cachedToken) return cachedToken;
// Perform fresh login
return await performLoginWithRetry(page, email, password);
} catch (error) {
console.error('❌ Token management failed:', error);
// Fallback: basic login without caching
console.log('⚠️ Falling back to uncached login');
return await performLogin(page, email, password);
}
}
async function performLoginWithRetry(page, email, password, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await performLogin(page, email, password);
} catch (error) {
console.error(`Login attempt ${attempt}/${maxRetries} failed:`, error.message);
if (attempt === maxRetries) throw error;
// Wait before retry (exponential backoff)
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
utilities/auth/clear-cache.js
const fs = require('fs').promises;
const path = require('path');
async function clearAuthCache() {
const tokenFiles = [
'auth-token.txt',
'auth-token-admin.txt',
'auth-token-database.txt',
'.auth-token*.txt'
];
for (const pattern of tokenFiles) {
try {
await fs.unlink(pattern);
console.log(`✅ Removed ${pattern}`);
} catch (error) {
// File doesn't exist - that's fine
}
}
console.log('✅ Auth cache cleared');
}
// Run as npm script
if (require.main === module) {
clearAuthCache();
}
module.exports = { clearAuthCache };

Add to package.json:

{
"scripts": {
"test": "playwright test",
"test:clear-cache": "node utilities/auth/clear-cache.js && npm test"
}
}

const DEBUG = process.env.DEBUG_AUTH === 'true';
function debugLog(message, data = null) {
if (!DEBUG) return;
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] 🔍 ${message}`);
if (data) {
console.log(JSON.stringify(data, null, 2));
}
}
async function getAuthToken(page, email, password) {
debugLog('getAuthToken called', { email, hasCachedToken: !!cachedToken });
if (cachedToken) {
debugLog('Checking cached token age', {
tokenAge: Date.now() - tokenTimestamp,
expirationMs: TOKEN_EXPIRATION_MS
});
}
// ... rest of implementation with debug logs
}

Run with debugging:

Terminal window
DEBUG_AUTH=true npx playwright test

Issue 1: Token works locally but fails in CI

Solution: Check token expiration time
- CI runs slower than local
- Token might expire mid-suite
- Use shorter expiration or refresh logic

Issue 2: Parallel tests sometimes fail auth

Solution: Implement locking mechanism
- Multiple workers competing for login
- Add file locks or worker coordination

Issue 3: File cache corruption

async function readTokenFromFile() {
try {
const data = await fs.readFile(TOKEN_FILE_PATH, 'utf8');
const parsed = JSON.parse(data);
// Validate structure
if (!parsed.token || !parsed.timestamp) {
throw new Error('Invalid token file structure');
}
return parsed;
} catch (error) {
// Corrupted file - delete and start fresh
await fs.unlink(TOKEN_FILE_PATH).catch(() => {});
return null;
}
}

Complete Example: Production-Ready Implementation

Section titled “Complete Example: Production-Ready Implementation”

Here’s a complete, production-ready token manager you can drop into your project:

utilities/auth/token-manager.js
const fs = require('fs').promises;
const path = require('path');
class TokenManager {
constructor(config = {}) {
this.config = {
expirationMs: config.expirationMs || 60 * 60 * 1000,
filePath: config.filePath || '.auth-token.txt',
lockTimeout: config.lockTimeout || 30000,
debug: config.debug || false
};
this.cache = new Map();
}
async getToken(page, email, password, context = 'default') {
this.log(`Getting token for context: ${context}`);
// Check memory cache
const cached = this.cache.get(context);
if (cached && !this.isExpired(cached.timestamp)) {
this.log('✅ Using memory cache');
return cached.token;
}
// Check file cache
const filePath = this.getFilePath(context);
const fileToken = await this.readFromFile(filePath);
if (fileToken && !this.isExpired(fileToken.timestamp)) {
this.log('✅ Using file cache');
this.cache.set(context, fileToken);
return fileToken.token;
}
// Need fresh login - acquire lock
await this.acquireLock(context);
try {
// Re-check after lock
const recheckFile = await this.readFromFile(filePath);
if (recheckFile && !this.isExpired(recheckFile.timestamp)) {
this.log('✅ Another worker logged in');
this.cache.set(context, recheckFile);
return recheckFile.token;
}
// Perform login
this.log('🔄 Performing fresh login');
const token = await this.performLogin(page, email, password);
const timestamp = Date.now();
// Cache in memory and file
this.cache.set(context, { token, timestamp });
await this.saveToFile(filePath, token, timestamp);
return token;
} finally {
await this.releaseLock(context);
}
}
async performLogin(page, email, password) {
// Navigate and login
await page.goto('/login');
await page.fill('[name="email"]', email);
await page.fill('[name="password"]', password);
// Intercept token from network
const tokenPromise = new Promise((resolve) => {
page.on('request', (request) => {
if (request.url().includes('/api/')) {
const token = request.headers()['authorization'];
if (token) resolve(token);
}
});
});
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
return await tokenPromise;
}
isExpired(timestamp) {
return (Date.now() - timestamp) >= this.config.expirationMs;
}
getFilePath(context) {
if (context === 'default') return this.config.filePath;
const ext = path.extname(this.config.filePath);
const base = path.basename(this.config.filePath, ext);
return `${base}-${context}${ext}`;
}
async readFromFile(filePath) {
try {
const data = await fs.readFile(filePath, 'utf8');
return JSON.parse(data);
} catch {
return null;
}
}
async saveToFile(filePath, token, timestamp) {
try {
await fs.writeFile(filePath, JSON.stringify({ token, timestamp }));
this.log('💾 Saved to file');
} catch (error) {
this.log(`⚠️ Failed to save: ${error.message}`);
}
}
async acquireLock(context) {
const lockFile = `${this.getFilePath(context)}.lock`;
const startTime = Date.now();
while (Date.now() - startTime < this.config.lockTimeout) {
try {
await fs.writeFile(lockFile, String(Date.now()), { flag: 'wx' });
return;
} catch {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
throw new Error('Lock timeout');
}
async releaseLock(context) {
const lockFile = `${this.getFilePath(context)}.lock`;
await fs.unlink(lockFile).catch(() => {});
}
log(message) {
if (this.config.debug) {
console.log(`[TokenManager] ${message}`);
}
}
async clearCache(context = null) {
if (context) {
this.cache.delete(context);
await fs.unlink(this.getFilePath(context)).catch(() => {});
} else {
this.cache.clear();
// Clear all token files
const files = await fs.readdir('.');
for (const file of files) {
if (file.includes('auth-token')) {
await fs.unlink(file).catch(() => {});
}
}
}
}
}
// Export singleton instance
const tokenManager = new TokenManager({
debug: process.env.DEBUG_AUTH === 'true'
});
module.exports = {
getAuthToken: (page, email, password, context) =>
tokenManager.getToken(page, email, password, context),
clearAuthCache: (context) =>
tokenManager.clearCache(context)
};

Smart token management transforms your test suite from slow and fragile to fast and reliable. By implementing caching, expiration handling, and proper concurrency controls, you can:

  • Reduce test execution time by 10x or more
  • Eliminate flaky authentication failures
  • Reduce server load from repeated logins
  • Enable efficient parallel test execution
  • Maintain test independence and reliability

The patterns shown here are battle-tested in production environments with hundreds of API tests running in parallel across multiple workers. Start with the basic implementation and add advanced features as your needs grow.

Key Takeaways:

  1. Cache tokens in memory and files
  2. Handle expiration automatically
  3. Use locks for parallel execution safety
  4. Support multiple authentication contexts
  5. Implement graceful fallbacks
  6. Add comprehensive debugging

Next Steps:

  • Implement basic token caching in your test suite
  • Measure the performance improvement
  • Add file persistence for parallel workers
  • Implement locking if you run tests in parallel
  • Consider encryption for sensitive environments

Your test suite will thank you. 🚀