Custom HTTP headers are everywhere in modern web apps. Authentication tokens, tenant identifiers, feature flags, CORS preflight, API versioning - the list goes on. When you're testing these apps, you need to inject the right headers at the right time.
This post breaks down the different approaches for setting headers in Playwright and Playwright MCP, when each makes sense, and the subtle differences between document requests and follow-up requests.
The three levels of header injection
Playwright gives you three distinct ways to set custom headers:
- Context-level - Headers applied to all requests in a browser context
- Request-level - Headers applied selectively via route interception
- API-level - Headers for direct API calls (not browser navigation)
Each serves different use cases. Let's look at them.
Context-level headers (extraHTTPHeaders)
When you create a browser context, you can pass extraHTTPHeaders to inject headers into every request:
const context = await browser.newContext({
extraHTTPHeaders: {
'X-Tenant-ID': 'acme-corp',
'Authorization': 'Bearer eyJhbGciOiJIUzI1...',
'X-Feature-Flags': 'new-dashboard,dark-mode'
}
});Every request from this context - navigation, XHR, fetch, images, scripts, stylesheets - gets these headers. This is the simplest approach and covers most use cases.
When to use it:
- Authentication tokens that should apply everywhere
- Tenant identifiers in multi-tenant apps
- Feature flags for A/B testing scenarios
- Any header that needs to be consistent across all requests
The gotcha: You can't conditionally apply headers per-request. If you need Authorization on API calls but not on CDN requests, this isn't your tool.
Request-level headers (route interception)
For granular control, Playwright lets you intercept requests and modify them:
await page.route('**/api/**', async (route) => {
const headers = {
...route.request().headers(),
'X-Request-ID': crypto.randomUUID(),
'X-Timestamp': Date.now().toString()
};
await route.continue({ headers });
});This example adds dynamic headers only to API requests while leaving other requests untouched.
You can also target specific request types:
await page.route('**/*', async (route) => {
const request = route.request();
// Only modify document requests (main HTML)
if (request.resourceType() === 'document') {
await route.continue({
headers: {
...request.headers(),
'X-Test-Mode': 'true'
}
});
} else {
await route.continue();
}
});When to use it:
- Different headers for different endpoints
- Dynamic values that change per-request
- Conditional logic based on URL patterns or request type
- Removing or overwriting specific headers
The cost: More code, more complexity, potential performance overhead from route matching.
Document requests vs follow-up requests
Here's a subtle distinction that trips people up.
When you navigate to a page, the browser makes a document request for the HTML. Then it parses that HTML and makes follow-up requests for scripts, styles, images, fonts, and API calls.
With extraHTTPHeaders, both get your custom headers. No distinction needed - everything is covered.
With route interception, you can distinguish them:
await page.route('**/*', async (route) => {
const resourceType = route.request().resourceType();
if (resourceType === 'document') {
// Main page navigation
console.log('Document request:', route.request().url());
} else if (resourceType === 'fetch' || resourceType === 'xhr') {
// API calls
console.log('API request:', route.request().url());
}
await route.continue();
});Resource types include: document, stylesheet, image, media, font, script, texttrack, xhr, fetch, eventsource, websocket, manifest, and other.
Why this matters: Some apps require auth headers only on API calls, not on static assets. CDN requests might need different caching headers. Debug headers might only make sense on document loads.
Playwright MCP: config-based headers
Playwright MCP takes a different approach. Instead of programmatic configuration, headers are declared in a JSON config file:
{
"browser": {
"browserName": "chromium",
"launchOptions": {
"headless": true
},
"contextOptions": {
"extraHTTPHeaders": {
"X-Tenant-ID": "acme-corp",
"Authorization": "Bearer token123"
}
}
}
}Pass this config to the MCP server via --config:
npx @playwright/mcp --config=./playwright-config.jsonEvery browser session spawned by the MCP server inherits these headers.
Why declarative? MCP is designed for AI agents that communicate via the Model Context Protocol. The agent doesn't write code - it sends tool calls. A config file lets you bake in headers without the agent needing to understand or manipulate them.
Playwright vs Playwright MCP: which to use?
| Scenario | Playwright | Playwright MCP |
|---|---|---|
| Programmatic tests (Jest, etc.) | ✓ Best choice | Overkill |
| AI agent automation | Works but awkward | ✓ Purpose-built |
| Dynamic headers per-request | ✓ Route interception | ✗ Limited |
| Static headers (auth, tenant) | extraHTTPHeaders | Config file |
| CI/CD integration | Either works | Either works |
Use Playwright directly when you're writing test code and need fine-grained control.
Use Playwright MCP when you're building AI-driven automation and want headers configured declaratively without the agent managing them.
Multi-tenant testing pattern
A common pattern: testing the same app across multiple tenants. Each tenant might have different features, data, or configurations.
With Playwright:
const tenants = ['acme', 'globex', 'initech'];
for (const tenant of tenants) {
const context = await browser.newContext({
extraHTTPHeaders: {
'X-Tenant-ID': tenant
}
});
const page = await context.newPage();
await page.goto('https://app.example.com');
// Run tests...
await context.close();
}With Playwright MCP, you'd generate different config files per tenant or pass headers dynamically if your tooling supports it.
Combining headers with cookies
Headers and cookies often work together for authentication. The pattern:
const context = await browser.newContext({
extraHTTPHeaders: {
'X-API-Version': '2024-01',
'X-Client': 'test-runner'
},
storageState: {
cookies: [{
name: 'session',
value: 'abc123',
domain: '.example.com',
path: '/'
}],
origins: []
}
});Headers go in the HTTP request. Cookies go in the Cookie header automatically. Both are set at context creation, both persist for the context lifetime.
In Playwright MCP, the --storage-state flag handles cookies while the config file handles headers. Same separation, different mechanism.
Debugging header issues
When headers aren't working as expected:
Check the actual request:
page.on('request', request => {
console.log(request.url());
console.log(request.headers());
});Verify via DevTools: Open the browser (non-headless) and check the Network tab. Are your headers showing up?
Watch for overwrites: Some frameworks add their own headers. If you're setting Authorization but the app's JavaScript also sets it, the app might win.
Case sensitivity: HTTP headers are case-insensitive by spec, but some servers misbehave. Authorization and authorization should be equivalent, but test to be sure.
How Test-Lab handles headers
At Test-Lab, we use Playwright's extraHTTPHeaders under the hood. When you configure headers at the project or test plan level, they're injected into the browser context before the AI agent starts navigating.
This means:
- Headers apply to all requests (consistent behavior)
- The AI agent never sees your header values (they're injected at the browser level)
- No risk of headers appearing in prompts or logs
We also support runtime headers via the API - generate fresh tokens in CI and pass them per-run.
The cascade works the same as cookie authentication: project → test plan → runtime, with later levels overriding earlier ones.
Summary
- Context-level (
extraHTTPHeaders): Simple, covers all requests, use for most cases - Request-level (route interception): Granular control, conditional logic, dynamic values
- Playwright MCP config: Declarative approach for AI agent scenarios
- Document vs follow-up: Only matters when you need different headers for different request types
For AI-driven testing, the config-based approach keeps complexity out of the agent's hands. For traditional test scripts, programmatic control gives you flexibility.
Full Playwright docs: Browser Context
Playwright MCP: GitHub
Need custom headers for your AI tests? Configure them in Test-Lab at project, test plan, or runtime level.
