← Blog

Setting Custom HTTP Headers in Playwright vs Playwright MCP

A deep dive into custom header injection - context-level vs request-level approaches, Playwright MCP configuration, and when to use each pattern.

technicalplaywrightheadersauthenticationtestingMCP
Setting Custom HTTP Headers in Playwright vs Playwright MCP

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:

  1. Context-level - Headers applied to all requests in a browser context
  2. Request-level - Headers applied selectively via route interception
  3. 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.json

Every 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?

ScenarioPlaywrightPlaywright MCP
Programmatic tests (Jest, etc.)✓ Best choiceOverkill
AI agent automationWorks but awkward✓ Purpose-built
Dynamic headers per-request✓ Route interception✗ Limited
Static headers (auth, tenant)extraHTTPHeadersConfig file
CI/CD integrationEither worksEither 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.

Ready to try Test-Lab.ai?

Start running AI-powered tests on your application in minutes. No complex setup required.

Get Started Free