Currents Team
Currents Team

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

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.

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

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 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) 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:

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:

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:

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() 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
Diagram showing where sensitive data can leak in Playwright tests, including traces, HAR files, screenshots, logs, and fixtures

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 TypeWhat's Inside?PersistenceTypical Access ScopeRisk LevelResponse Priority
trace.zipNetwork headers, cookies, DOM snapshots, PII.Persistent (Serialized)CI history, dashboards, local downloadsCRITICALImmediate action required; restrict access and avoid persisting artifacts longer than necessary
storageState.jsonActive session cookies, LocalStorage, Auth tokens.Persistent (JSON)Project directory, Git (if not ignored)CRITICALImmediate action required; restrict access and avoid persisting artifacts longer than necessary
HAR FilesHTTP request/response history.Persistent (JSON)Repositories, debug foldersHIGHPrioritize review; control access and monitor for unintended exposure
Screenshots/VideoVisible UI state, potentially PII or tokens.Persistent (Media)Dashboards, triage channelsMEDIUMAllow for debugging; apply masking and standard access controls
Reporter Logsstdout, step labels, error stacks.Persistent (Text)CI logs, log aggregatorsLOW/MEDIUMHygiene-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:

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.

// 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():

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:

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:

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 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:

// 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.

// 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.

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:

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, 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:

// 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!);
}
// 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:

# .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:

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:

// 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:

// 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 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:

#!/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 to delete specific artifacts:

    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 to rewrite history:

    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.


Scale your Playwright tests with confidence.
Join hundreds of teams using Currents.
Learn More

Trademarks and logos mentioned in this text belong to their respective owners.

Related Posts