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.
The Problem: Login Fatigue
Section titled “The Problem: Login Fatigue”Consider a typical API test suite without token management:
// Every single test does thistest('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
The Solution: Smart Token Management
Section titled “The Solution: Smart Token Management”A production-ready token management system provides:
- Caching: Store tokens in memory and reuse them
- Persistence: Save tokens to disk to survive test worker restarts
- Expiration Handling: Automatically refresh when tokens expire
- Multiple Contexts: Support different auth tokens for different services
- Concurrency Safety: Handle parallel test execution
Let’s build this step by step.
Architecture Overview
Section titled “Architecture Overview”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.
Implementation: Step-by-Step
Section titled “Implementation: Step-by-Step”Step 1: Basic Token Manager Structure
Section titled “Step 1: Basic Token Manager Structure”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
Step 2: Implement Token Persistence
Section titled “Step 2: Implement Token Persistence”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.txtStep 3: Implement Login Logic
Section titled “Step 3: Implement Login Logic”The actual authentication depends on your application. Here are common patterns:
Pattern A: API-Based Login
Section titled “Pattern A: API-Based Login”/** * 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
Step 4: Using Token Manager in Tests
Section titled “Step 4: Using Token Manager in Tests”Now tests become beautifully simple:
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)
Advanced Patterns
Section titled “Advanced Patterns”Pattern 1: Multiple Authentication Contexts
Section titled “Pattern 1: Multiple Authentication Contexts”Many applications need different tokens for different services:
// Separate tokens for different servicesconst 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;}
// Usageconst appToken = await getAuthToken(page, email, password, 'app');const adminToken = await getAuthToken(page, adminEmail, adminPassword, 'admin');const dbToken = await getAuthToken(page, email, password, 'database');Pattern 2: Configurable Token Expiration
Section titled “Pattern 2: Configurable Token Expiration”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' }};
// Usageconst 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
Performance Impact
Section titled “Performance Impact”Real-world results from a test suite with 150 API tests:
Before Token Caching:
Section titled “Before Token Caching:”Total time: 18m 32sLogin operations: 150Average login time: 7.2sTime spent logging in: 18m 0s (97% of total time!)Actual test time: 32sAfter Token Caching:
Section titled “After Token Caching:”Total time: 1m 45sLogin operations: 3 (one per test worker)Average login time: 7.2sTime spent logging in: 21.6s (20% of total time)Actual test time: 1m 23s
Speed improvement: 10.5x faster 🚀Best Practices
Section titled “Best Practices”1. Environment-Specific Configuration
Section titled “1. Environment-Specific Configuration”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];2. Secure Token Storage
Section titled “2. Secure Token Storage”// For sensitive environments, encrypt tokens at restconst 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;}3. Graceful Degradation
Section titled “3. Graceful Degradation”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)); } }}4. Clear Cache Utility
Section titled “4. Clear Cache Utility”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 scriptif (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" }}Debugging Token Issues
Section titled “Debugging Token Issues”Enable Detailed Logging
Section titled “Enable Detailed Logging”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:
DEBUG_AUTH=true npx playwright testCommon Issues and Solutions
Section titled “Common Issues and Solutions”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 logicIssue 2: Parallel tests sometimes fail auth
Solution: Implement locking mechanism- Multiple workers competing for login- Add file locks or worker coordinationIssue 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:
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 instanceconst 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)};Conclusion
Section titled “Conclusion”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:
- Cache tokens in memory and files
- Handle expiration automatically
- Use locks for parallel execution safety
- Support multiple authentication contexts
- Implement graceful fallbacks
- 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. 🚀