---
title: "How Playwright Tests Leak Data (and How to Stop It)"
description: "Playwright test data leaks rarely come from bad secrets hygiene, they come from test artifacts, fixtures, and reporters. Here's how to find and close that gap."
canonical: "https://currents.dev/posts/playwright-avoid-data-leak"
updated: "2026-06-02"
---

# How Playwright Tests Leak Data (and How to Stop It)

Published 2026-06-02 by Currents Team.

Canonical URL: https://currents.dev/posts/playwright-avoid-data-leak

Playwright test data leaks rarely come from bad secrets hygiene, they come from test artifacts, fixtures, and reporters. Here's how to find and close that gap.

---


If you don't control what Playwright captures during test runs, your traces, screenshots, and reports can leak credentials, tokens, and PII. And if you're [migrating from older tools](/posts/how-to-adopt-playwright-the-right-way) like Selenium or Cypress, you're probably not prepared for it.

This exposure doesn't come from mishandling `.env` files or CI secrets. You likely handle secrets correctly at the application level. The gap is in your test code and artifacts: fixture values logged during retries, auth tokens embedded in trace files, API keys in report output, or full request/response bodies captured in HAR archives.

Playwright serializes browser state, headers, session cookies, network activity, and DOM snapshots. Without deliberate controls, this data flows into CI artifacts, dashboards, and log aggregators, often without anyone noticing.

This article covers the specific ways Playwright leaks data, and how to close each one.

**When you can skip this:** If you're running tests locally against a throwaway environment with no real credentials, synthetic data, and no CI pipeline, most of this doesn't apply. The risks here matter when real tokens, real PII, or real credentials flow through your test suite and the artifacts end up in shared systems.

## Where Sensitive Data Actually Leaks in Playwright Test Files

Playwright prioritizes making every piece of evidence available after a failure. Each of its debugging mechanisms can cross the line from useful diagnostic data to exposed sensitive data.

### **Traces and trace.zip Archives**

The `trace.zip` file is an archive containing serialized execution data from the browser. Inside a Playwright trace, you can see DOM snapshots, test events, and network activity, such as requests and response headers and bodies. When you set `trace: 'on'`, `trace: 'on-first-retry'`, or start tracing before the login or session initialization step, any tests that interact with authentication flows, or API requests may capture sensitive information.

Credentials, raw Authorization headers, bearer tokens, and `Set-Cookie` instructions are serialized from your server and stored in a trace archive. These files, when uploaded to CI artifact stores or external dashboards (including [Currents](https://docs.currents.dev/)) without filtering, expose the secrets they contain.

### **Screenshots and Videos**

Playwright can be configured to take screenshots or make videos of test failures. If the test were midway through a `locator.fill()` call on a sensitive field, or if a secret key were momentarily visible in a toast notification, that data would appear in the `.png` file.

Videos are worse. They capture the entire test viewport for the full duration. Any data rendered on screen during the test ends up in the `.webm` file. This includes credentials autofilled by password managers, tokens visible in admin panels, and any sensitive content rendered in the page during slow-motion or headed execution.

### **Console Logs and** `test.info().attach()`

`console.log` calls left in tests or fixtures write to the CI runner's standard output. If they contain sensitive data, that output can be indexed by log aggregators.

Retries compound this risk. Playwright re-runs the test code on each retry, so any `console.log` calls execute again. A single sensitive value can appear multiple times in CI logs, SIEM platforms, and external dashboards.

Additionally, `test.info().attach()` is commonly used to debug API failures. For example:

```typescript
const response = await request.get("/api/user");
await test.info().attach("user-data", {
  body: await response.text(),
  contentType: "application/json",
});
```

If the response contains personally identifiable information (PII) or internal system tokens, that data is moved into test reports.

### **Fixture Leakage via** **test.use()** **and Shared State**

Fixtures used to provision auth sessions, API tokens, or database credentials can leak if they surface their setup output as part of test step annotations.

For example:

```typescript
test.info().annotations.push({ type: "token", description: token });
```

This makes the token visible in the report UI.

Worker-scoped fixtures can also introduce risk. They hold long-lived tokens that are reused across multiple tests to avoid repeated authentication flows. If a worker process crashes or logs an error, the state of that fixture, including the token, can surface indirectly in the error stack trace or diagnostic output.

### **Environment Variables in Test Output**

Within a `test.step()` function, values referenced through `process.env` can be surfaced in reports.

For example:

```typescript
await test.step(`Login as ${process.env.ADMIN_USER}`, async () => {
  // step logic
});
```

This would print the value of `ADMIN_USER` in the CI logs and the HTML report.

Also, when sensitive values are included in the metadata and use blocks of the `playwright.config.ts` file, they can be written to the `results.json` file. This is because many custom reporters take the `FullConfig` object and serialize it to a JSON file.

### **HAR Files**

When you use `recordHar` (or the newer [`context.tracing.startHar()`](https://playwright.dev/docs/api/class-tracing#tracing-start-har) API added in Playwright 1.60) to capture network traffic, or `page.routeFromHAR()` to replay it, Playwright stores request and response headers and bodies in a `.har` file.

If valid credentials are used while recording and the files are committed, those credentials become searchable in your repository's history. The newer `startHar()` API supports a `content: 'omit'` option that strips response bodies, which reduces exposure if you only need request/response metadata for routing.

![Diagram showing where sensitive data can leak in Playwright tests, including traces, HAR files, screenshots, logs, and fixtures](/img/posts/playwright-data-leak/diagram.png)

## Which Artifacts Carry the Most Risk?

The risk isn't in runtime memory. Secrets in worker process memory die when the process exits, protected by OS-level isolation. The risk is in persisted artifacts: traces, HAR files, screenshots, and storage state files that survive the test run and flow into CI artifact stores, dashboards, and log aggregators.

Not all artifacts carry the same risk. CI secret masking only covers log output. It doesn't touch trace archives, HAR files, or `storageState.json`. Once a secret is serialized into one of these artifacts, it's accessible in plaintext to anyone with read access to your CI artifacts or repo.

| Artifact Type     | What's Inside?                                     | Persistence             | Typical Access Scope                    | Risk Level | Response Priority                                                                               |
| ----------------- | -------------------------------------------------- | ----------------------- | --------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------- |
| trace.zip         | Network headers, cookies, DOM snapshots, PII.      | Persistent (Serialized) | CI history, dashboards, local downloads | CRITICAL   | Immediate action required; restrict access and avoid persisting artifacts longer than necessary |
| storageState.json | Active session cookies, LocalStorage, Auth tokens. | Persistent (JSON)       | Project directory, Git (if not ignored) | CRITICAL   | Immediate action required; restrict access and avoid persisting artifacts longer than necessary |
| HAR Files         | HTTP request/response history.                     | Persistent (JSON)       | Repositories, debug folders             | HIGH       | Prioritize review; control access and monitor for unintended exposure                           |
| Screenshots/Video | Visible UI state, potentially PII or tokens.       | Persistent (Media)      | Dashboards, triage channels             | MEDIUM     | Allow for debugging; apply masking and standard access controls                                 |
| Reporter Logs     | stdout, step labels, error stacks.                 | Persistent (Text)       | CI logs, log aggregators                | LOW/MEDIUM | Hygiene-focused; apply filtering and standard retention policies                                |

## Playwright's Built-in Controls for Sensitive Data

Playwright's job is to run tests, not to secure secrets. But it provides several mechanisms you can use to keep debugging features from exposing credentials.

### **Scrub Artifacts Before They Leave the Runner**

Dashboard-level filtering and log masking don't cover artifact leaks. You need to sanitize artifacts before they leave the test runner. Swap authorization headers for placeholders, mask sensitive DOM elements before screenshots, and prune high-privileged cookies from storage state before saving.

For example, before saving `storageState.json`, strip sensitive cookies:

```typescript
const state = JSON.parse(fs.readFileSync("storageState.json", "utf-8"));

state.cookies = state.cookies.filter(
  (cookie) => !["admin_session", "internal_token"].includes(cookie.name),
);

fs.writeFileSync("storageState.json", JSON.stringify(state, null, 2));
```

### `use.extraHTTPHeaders` **and Header Filtering**

Instead of manually passing a token for every API call, you can define it once in your `playwright.config.ts` file. Token injection can be handled through `extraHTTPHeaders` while using `process.env` to retrieve the secret from your CI vault.

```typescript
// playwright.config.ts
export default defineConfig({
  use: {
    // High-level injection: tokens move from environment to headers
    // without appearing in test logic.
    extraHTTPHeaders: {
      Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`,
    },
  },
});
```

This keeps tokens out of your test code, which improves hygiene. But there's a real tradeoff: `extraHTTPHeaders` attaches the header to every request in the browser context. That means every network request captured in a trace or HAR file will carry your authorization token. If you're tracing broadly, this can make things worse, not better.

If you only need the token for specific API routes, a better approach is to inject it selectively using `page.route()`:

```typescript
await page.route("**/api/**", async (route) => {
  await route.continue({
    headers: {
      ...route.request().headers(),
      Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`,
    },
  });
});
```

This limits token exposure to matching requests only, reducing how many trace entries contain the secret.

You should also use static labels within Playwright's `test.step()` calls to avoid printing secrets in strings.

Bad example:

```typescript
await test.step(`Submit data with token: ${token}`, async () => { ... });
```

Here, the step name is rendered with the token present: `Submit data with token: eyJhbGciOiJIUzI1...`

Good example:

```typescript
await test.step('Submit authenticated data payload', async () => { ... });
```

In this case, the sensitive value remains within the function scope, and the token is not printed in the step name.

### **Redacting Sensitive Values in Reporters**

The [Playwright reporter](https://playwright.dev/docs/api/class-reporter) architecture acts as a final checkpoint before data reaches your CI logs or external dashboards. This only covers reporter output streams, not artifacts like traces, screenshots, or HAR files. But within that boundary, you can build a scrubbing reporter that checks for known secret patterns before writing to `stdout` or `stderr`.

Use hooks like `onStepEnd` and `onTestEnd` to intercept output and run regex-based redaction:

```typescript
// redacting-reporter.ts
import {
  Reporter,
  TestStep,
  TestCase,
  TestResult,
} from "@playwright/test/reporter";

const SECRET_PATTERNS = [
  /(Bearer\s+[A-Za-z0-9\-\._~\+\/]+=*)/g,
  /(sk_live_[A-Za-z0-9]{24,})/g,
  /(ghp_[A-Za-z0-9]{36,})/g,
  /(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g,
];

function redact(value: string): string {
  let result = value;
  for (const pattern of SECRET_PATTERNS) {
    result = result.replace(pattern, "[REDACTED]");
  }
  return result;
}

export default class RedactingReporter implements Reporter {
  onStepEnd(test: TestCase, result: TestResult, step: TestStep) {
    const safeTitle = step.title ? redact(step.title) : "";
    process.stdout.write(`STEP: ${safeTitle}\n`);
  }

  onTestEnd(test: TestCase, result: TestResult) {
    if (result.error?.message) {
      process.stderr.write(`ERROR: ${redact(result.error.message)}\n`);
    }

    for (const attachment of result.attachments) {
      if (attachment.body) {
        attachment.body = Buffer.from(
          redact(attachment.body.toString("utf-8")),
        );
      }
    }
  }
}
```

This covers step titles, error messages, and attachment bodies. Note that reporters only see the data flowing through their hooks. They cannot redact traces, screenshots, or HAR files, which are written directly to disk by Playwright's tracing engine. For those, you need the artifact-level controls described earlier.

### **Controlling What Gets Captured in Traces**

If you're capturing traces for every test (`trace: 'on'`), you're generating far more artifacts than you need, and each one can contain secrets. Use `retain-on-failure` instead. This captures traces for all tests but only persists them for failures, significantly reducing the number of artifacts that contain credentials.

You can also control which parts of a test are included in a trace. The simplest approach: don't start tracing until after sensitive operations like login are complete.

```typescript
// Perform login BEFORE tracing starts — nothing here is captured
await login(page);

// Start tracing only for the actual test interactions
await context.tracing.start({ screenshots: true, snapshots: true });
await page.goto("/dashboard");
await page.locator("[data-test=generate-report]").click();
await context.tracing.stop({ path: "trace.zip" });
```

If you need tracing active for the full context lifecycle but want to exclude specific segments from the saved file, use `startChunk()` / `stopChunk()`. Only the chunk saved via `stopChunk({ path })` is written to disk. Anything traced outside a chunk boundary is discarded when you use this pattern.

```typescript
await context.tracing.start({ screenshots: true, snapshots: true });

// Login is traced in memory but won't be saved — no chunk is active
await login(page);

// Start a chunk for the feature test only
await context.tracing.startChunk();
await page.goto("/dashboard");
await page.locator("[data-test=generate-report]").click();

// Only this chunk is written to disk
await context.tracing.stopChunk({ path: "trace.zip" });
```

Most teams rely on configuration-level tracing (`trace: 'retain-on-failure'`), which is the right default. The patterns above are for cases where you need finer control, like excluding authentication setup from trace artifacts while still tracing the rest of the test.

You can also reduce what traces contain without changing when they're captured. The `trace` config option accepts an object where you can disable specific features:

```typescript
use: {
  trace: {
    mode: "retain-on-failure",
    snapshots: false,
    screenshots: true,
  },
}
```

Setting `snapshots: false` disables DOM snapshots in traces, which removes a major source of PII exposure while still keeping timing data and screenshots for debugging.

### `browserContext.clearCookies()` **and Session Teardown**

By default, Playwright provides isolation through separate browser contexts per test, but data bleed can occur due to sequential execution within a worker. A worker-scoped fixture could retain state across multiple test files assigned to it, causing the next test to inherit context from the previous one, potentially containing sensitive data.

Add teardown logic inside your authentication fixtures. Call `await context.clearCookies()` to wipe session cookies and identity state, even if the worker stays alive. This prevents ghost sessions from being captured in the next test's trace.

## Secrets Management: Beyond `.env` Files

Storing secrets in `.env` files instead of hardcoding them is table stakes. If you're running multi-environment pipelines, shared test infrastructure, or working in a regulated environment, you need CI-native secrets or an external secrets manager.

### **CI-Native Secrets**

CI-native secrets in [GitHub Actions](https://docs.currents.dev/getting-started/ci-setup/github-actions/playwright-github-actions), GitLab CI, or CircleCI are safer than `.env` files, but they become plain strings the moment they hit the runner environment.

CI systems mask these values in logs, but masking breaks when the secret appears in a modified form: base64-encoded, truncated, or concatenated with other strings. The masking engine no longer recognizes the original value.

The fix: prevent raw secret values from appearing in test steps, labels, annotation strings, and `console.log` output in the first place.

### **External Secrets Managers**

With external secrets managers such as Vault, AWS Secrets Manager, or GCP Secret Manager, tokens can be fetched during test-run initialization rather than per test. Use `globalSetup` to retrieve secrets once and `globalTeardown` to clean up:

```typescript
// global-setup.ts
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import fs from "fs";

export default async function globalSetup() {
  const client = new SecretsManagerClient({ region: "us-east-1" });
  const { SecretString } = await client.send(
    new GetSecretValueCommand({ SecretId: "test-suite/credentials" }),
  );
  fs.writeFileSync(".env.tmp", SecretString!);
}
```

```typescript
// global-teardown.ts
import fs from "fs";

export default async function globalTeardown() {
  if (fs.existsSync(".env.tmp")) fs.unlinkSync(".env.tmp");
}
```

This works, but comes with trade-offs:

- **Latency:** Cold-starting a vault connection can add two to five seconds to the test run time. Reduce this delay by connecting once per suite run rather than per test.
- **IAM surface area:** If your CI runner has read-all-secrets permissions across production and test environments, a compromised test script could exfiltrate all credentials. Scope the test runner so it only has access to the specific secret paths required for the test run.
- **Rotation during a suite:** If a secret is rotated while a suite is running, tests may fail. A common practice is to fetch a specific secret version at the beginning of the run and keep it valid for the duration of the suite.

An even safer approach when using external managers is to issue ephemeral secrets in test environments that support them. The runner can authenticate to the secrets vault using workload identity, such as OpenID Connect (OIDC), and obtain a temporary token for the test run. Since these tokens have a short time-to-live (TTL), even if they leak, the exposure window is very limited.

### `.env` **File Discipline**

Even with external vaults and CI-native secrets, `.env` files remain the primary interface for local development. The most common leak vector isn't the `.env` file itself, it's the variations that slip through `.gitignore`.

An engineer creates `.env.backup` or `.env.staging` and assumes it's ignored because `.env` is in `.gitignore`. A safer pattern is to ignore all `.env` variations and explicitly allow non-secret files:

```plaintext
# .gitignore
.env*
!.env.test
!.env.template
```

Also make sure `.env` values don't override secrets injected by CI or external managers. Configure `dotenv` so existing environment variables take precedence:

```javascript
require("dotenv").config({ override: false });
```

### **Playwright-Specific Secret Exposure Patterns**

The patterns above apply to any test framework. Playwright's own architecture introduces additional vectors worth understanding.

**`storageState` files contain live sessions.** The recommended Playwright auth pattern writes browser state to disk via `page.context().storageState({ path: authFile })`. That file contains all cookies, localStorage entries, and IndexedDB (if indexedDB: true is set) data from the authenticated session. If it's written outside the project's `outputDir` (which Playwright cleans between runs), stale session files can accumulate. Store auth state inside `playwright/.auth/`, add that directory to `.gitignore`, and use `outputDir`-relative paths so Playwright handles cleanup:

```typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/ },
    {
      name: "chromium",
      use: {
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
});
```

**Project dependencies control when secrets are handled.** The `dependencies` field in Playwright config determines execution order between projects. Your setup project (which performs login and writes `storageState`) always runs before test projects that depend on it. This means the window where credentials are actively used in the browser is limited to the setup project's execution. If you're fetching secrets from a vault, do it in the setup project rather than in `globalSetup`, because setup projects participate in Playwright's reporting and tracing pipeline. That gives you visibility into auth failures without scattering secret-fetching logic across `globalSetup`, fixtures, and test files.

**Sharding multiplies secret access.** Each shard is a separate `npx playwright test` invocation, so `globalSetup` runs once per shard. If you have 8 shards, your vault gets 8 authentication requests instead of 1. This matters for rate-limited secrets managers and for audit logs: a single test run generates N vault access events instead of one, which can trigger alerts. Setup projects have the same behavior with sharding: each shard runs its own instance of the setup project. If you're using the setup project pattern for auth, each shard performs its own login flow and writes its own `storageState` file. Plan your test account pool accordingly. If your secrets manager supports it, fetch credentials once in `globalSetup`, write them to a temporary file, and let setup projects read from that file rather than hitting the vault again.

## Securing Playwright Output in External Dashboards

When you send test results to external platforms like Currents, your test runner acts as a data transmitter. Whatever is captured in traces, logs, or attachments gets transmitted.

Data should be clean before it leaves the runner. Don't rely on the dashboard to filter or redact sensitive information after it's been captured. This means:

- Audit which attachments are sent through `test.info().attach()`
- Use `retain-on-failure` trace mode to reduce the number of persisted artifacts that can be uploaded
- Avoid raw HTTP response bodies as attachments, since they may contain PII or credentials
- Review custom reporter output before enabling stdout forwarding to external systems

You can also control what gets forwarded by scoping which artifacts Playwright generates in the first place:

```typescript
// playwright.config.ts
import { currentsReporter } from "@currents/playwright";

export default defineConfig({
  reporter: [currentsReporter()],
  use: {
    trace: "retain-on-failure",
    video: "off",
    screenshot: "only-on-failure",
  },
});
```

Don't disable all artifacts. Instead, scope them by sensitivity: use `retain-on-failure` for traces so only failures produce trace files, turn off video if your tests handle credentials on screen, and keep screenshots for failure debugging.

Currents handles encryption at rest, role-based access controls, and [SOC 2 compliance](/security#encryption) on its end. But even with those controls, a leaked JWT or API key in a trace is still exploitable. The runner-side cleanup described above is your primary defense.

## Auditing Your Playwright Suite for Data Leaks

Run these checks periodically against your codebase. Save this as a script (e.g., `scripts/audit-test-secrets.sh`) and add it to your CI pipeline or run it locally before a release:

```bash
#!/usr/bin/env bash
set -euo pipefail

echo "=== Env vars in step labels or annotations ==="
rg 'test\.step\(.*process\.env' --glob '*.spec.ts' --glob '*.test.ts' || true
rg 'annotations\.push.*process\.env' --glob '*.ts' || true

echo "=== console.log in fixtures/setup ==="
rg 'console\.log' --glob '*.fixture.ts' --glob '*.setup.ts' || true

echo "=== trace: 'on' in config (use retain-on-failure instead) ==="
rg "trace:\s*['\"]on['\"]" playwright.config.ts || true

echo "=== storageState or HAR files tracked in git ==="
git ls-files '*.har' '*storageState*' || true

echo "=== Bearer tokens in trace zips ==="
find test-results -name '*.zip' -exec unzip -p {} \; 2>/dev/null | rg -c 'Bearer ' || true

echo "=== Done ==="
```

Beyond these automated checks, also review:

- All `test.info().attach()` calls. Check whether any `application/json` bodies could contain tokens or PII.
- Whether your `globalSetup` secrets fetching has a corresponding `globalTeardown` cleanup.

## If You've Already Leaked

If you discover that traces or artifacts have been shipping with credentials, work through this list in order:

1. **Rotate every exposed credential immediately.** Don't wait to finish the investigation. Any token, API key, or session cookie that appeared in a trace, HAR file, or `storageState.json` should be considered compromised. Rotate it now, audit later.

2. **Purge CI artifact stores.** Most CI providers let you delete artifacts by run ID or set a retention policy. In GitHub Actions, use the [REST API](https://docs.github.com/en/rest/actions/artifacts#delete-an-artifact) to delete specific artifacts:

   ```bash
   gh api -X DELETE repos/{owner}/{repo}/actions/artifacts/{artifact_id}
   ```

   For bulk cleanup, list artifacts by workflow run and delete all from the affected period.

3. **Scrub git history for committed secrets.** If `storageState.json`, `.har` files, or `.env` variants were committed, removing them from the current branch isn't enough. They're still in git history. Use [`git filter-repo`](https://github.com/newren/git-filter-repo) to rewrite history:

   ```bash
   git filter-repo --path playwright/.auth/user.json --invert-paths
   git filter-repo --path-glob '*.har' --invert-paths
   ```

   Force-push the cleaned history and notify your team to re-clone.

4. **Request deletion from external dashboards.** If traces or reports were sent to external platforms (Currents, Datadog, Grafana, etc.), check their data retention settings and request deletion of the affected runs. Most platforms support per-run deletion or retention policy changes.

5. **Check downstream consumers.** Artifacts don't just sit in CI. They flow into log aggregators, SIEM platforms, Slack channels (via CI notifications), and shared drives. Trace back where the affected artifacts were forwarded and clean up each destination.

6. **Add the audit script to CI.** Use the audit script from the previous section as a CI gate so new leaks are caught before they ship. Don't treat this as a one-time cleanup.

## Final Considerations

Most leaks come from accidental exposure, not malicious infiltration. A trace file uploaded to a public CI artifact store, a screenshot checked into a repo, or reporter output streamed to a log aggregator can pass sensitive data without anyone noticing.

The answer is not to turn off traces, videos, reporters, or screenshots. They're useful Playwright features, especially for debugging. Instead, use the controls in this article to scope what gets captured, sanitize artifacts before they leave the runner, and treat your test infrastructure with the same security hygiene you apply to application code. Platforms like [Currents](/) can centralize test visibility without you managing artifact storage or access controls yourself.

The risk is rarely in the tools themselves. It's in how their outputs are handled. If you assume secret masking alone covers you, you're exposed.

