PRACTICAL GUIDE / Selenium BiDi network log secret redaction

Stop Selenium BiDi network logs from leaking test credentials

Capture useful Selenium BiDi network evidence while removing authorization headers, cookies, query secrets, and response tokens before CI upload.

By The Testing AcademyUpdated August 4, 202624 min read
All field guides
In this guide7 sections
  1. Where the leak enters your evidence pipeline
  2. Build a safe request record at capture time
  3. Cover response headers, cookies, and URLs with explicit rules
  4. Prove the uploaded artifact, not only the helper
  5. Tell a redaction defect from a missing BiDi event
  6. Roll the policy through an existing suite
  7. Know when redaction is the wrong control

What you will learn

  • Where the leak enters your evidence pipeline
  • Build a safe request record at capture time
  • Cover response headers, cookies, and URLs with explicit rules
  • Prove the uploaded artifact, not only the helper

Your authentication test fails in CI, so you attach the BiDi request events to the report. The failure becomes easy to diagnose, but the artifact now contains a bearer token that still works. A useful network capture has turned one test failure into a credential incident.

Where the leak enters your evidence pipeline

WebDriver BiDi sends events over a bidirectional connection while the browser is running. A before-request event is not a harmless access-log line. Its request data can include the full URL, method, headers, cookies, request identifier, body size, and timing details. Response events can carry status data and response headers. That is exactly why the events are valuable during a failed login or API-driven UI test.

It is also why a listener deserves the same care as application logging. The Selenium network documentation shows listeners being registered with Network before navigation. Selenium's RequestData API exposes headers and cookies as structured values. The WebDriver BiDi network model defines those fields as event data. None of those contracts says that credential values are replaced for a test reporter.

The first risky assumption is that HTTPS protects the log. HTTPS protects traffic between the browser and server. It does not protect a Java object after the browser has reported the request to the test process. Once code calls a header value getter, serializes the event, or interpolates the object into an assertion message, the credential has crossed into another system.

Authorization is the obvious field. It is not the only one. A practical inventory usually finds several secret-bearing locations:

  • Request headers such as Authorization, Cookie, Proxy-Authorization, X-API-Key, and product-specific session or CSRF headers.
  • Response headers such as Set-Cookie, Location, and authentication challenges. A redirect target can place a one-time code in its query string.
  • Query parameters named token, code, key, signature, email, or session. Teams also put account identifiers in paths.
  • Request and response bodies containing passwords, refresh tokens, personal data, signed upload policies, or GraphQL variables.
  • Reporter metadata derived from the event, including assertion messages, debug snapshots, retry summaries, and exception objects.

Header names are case-insensitive. A rule that catches Authorization but misses authorization is not a rule. It is a test that happened to pass with one browser version. Duplicate header fields matter too. Converting a list to a map can silently keep one Set-Cookie value and discard another, which makes the evidence incomplete while giving reviewers false confidence.

URLs need separate treatment. Removing only the query string may still leave an email address, tenant identifier, reset token, or object key in the path. Keeping only the origin and a known route template is safer than trying to guess which path segment is private. Fragments are not normally part of an HTTP request, but copying the browser's current page URL from another source can reintroduce them.

The safest boundary is a one-way projection. The callback receives the event, extracts a small set of approved diagnostic fields, replaces sensitive values, and returns a different type. Only that safe type can be accepted by the reporter. Do not put the raw event into a queue for later redaction. Queues are inspectable in heap dumps, may be logged on failure, and often outlive the test that created them.

This runnable Java listener performs that projection inside the callback. It retains approved route labels and redaction markers, but never reads or stores a sensitive header value:

Java
import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.Header;
import org.openqa.selenium.bidi.network.RequestData;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.support.ui.WebDriverWait;

public final class CaptureSafeRequests {
    private static final Set<String> SENSITIVE_HEADERS = Set.of(
            "authorization", "cookie", "proxy-authorization", "x-api-key");

    public static void main(String[] args) {
        if (args.length != 1) {
            throw new IllegalArgumentException(
                    "Usage: CaptureSafeRequests <url>");
        }

        FirefoxOptions options = new FirefoxOptions();
        options.setCapability("webSocketUrl", true);
        WebDriver driver = new FirefoxDriver(options);
        List<SafeRequest> records = new CopyOnWriteArrayList<>();

        try (Network network = new Network(driver)) {
            network.onBeforeRequestSent(event ->
                    records.add(SafeRequest.from(event.getRequest())));

            driver.get(args[0]);
            new WebDriverWait(driver, Duration.ofSeconds(5))
                    .until(ignored -> !records.isEmpty());
            records.forEach(System.out::println);
        } finally {
            driver.quit();
        }
    }

    record SafeHeader(String name, String value) {}

    record SafeRequest(
            String requestId,
            String method,
            String route,
            List<SafeHeader> headers) {

        static SafeRequest from(RequestData request) {
            String route = classify(request.getUrl());
            List<SafeHeader> headers = request.getHeaders().stream()
                    .map(Header::getName)
                    .map(name -> name.toLowerCase(Locale.ROOT))
                    .filter(SENSITIVE_HEADERS::contains)
                    .distinct()
                    .map(name -> new SafeHeader(name, "[REDACTED]"))
                    .toList();

            return new SafeRequest(
                    request.getRequestId(),
                    request.getMethod(),
                    route,
                    headers);
        }

        private static String classify(String rawUrl) {
            try {
                String path = URI.create(rawUrl).getPath();
                return path != null
                        && Set.of("/login", "/api/profile").contains(path)
                        ? path
                        : "<unclassified>";
            } catch (IllegalArgumentException invalidUrl) {
                return "<invalid-url>";
            }
        }
    }
}

An allowlist is usually easier to defend than a growing secret-name denylist. Method, route template, status, MIME type, body size, request identifier, and a test-owned correlation value answer many diagnostic questions. Raw Cookie and Authorization values almost never do. For a sensitive header, recording the normalized name with a REDACTED marker can prove that the browser sent the field without preserving its contents.

This design has a precise limit. The Selenium client still receives the raw BiDi event in its process. Capture-time projection reduces retention and propagation; it does not make the secret absent from process memory. If the requirement says production credentials must never enter the automation worker, use synthetic short-lived credentials or avoid subscribing to the sensitive event. Redaction is not a substitute for a sound test-data policy.

Build a safe request record at capture time

The first example creates a local page that sends a fake bearer canary and a private account query value. A BiDi listener watches the API call, but the reporter receives only a SafeRequest record. The route is classified, the query is discarded, known sensitive headers get a marker, and unknown headers are omitted.

This is a JUnit 5 test for Selenium Java. It uses Firefox because Selenium's official Java event example enables the webSocketUrl capability on Firefox before creating Network. The project needs selenium-java and junit-jupiter in its test dependencies, plus a compatible Firefox installation and driver.

Java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.Header;
import org.openqa.selenium.bidi.network.RequestData;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
import org.openqa.selenium.support.ui.WebDriverWait;

class RedactedBiDiCaptureTest {
    private static final String CANARY = "qa-canary-token-not-a-real-secret";

    private HttpServer server;
    private WebDriver driver;
    private String baseUrl;

    @BeforeEach
    void startBrowserAndServer() throws IOException {
        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/", exchange -> {
            String html = """
                <!doctype html>
                <title>BiDi redaction fixture</title>
                <script>
                  fetch("/api/profile?account=alice%%40example.test&view=summary", {
                    headers: {
                      "Authorization": "Bearer %s",
                      "X-Request-Id": "case-42"
                    }
                  });
                </script>
                """.formatted(CANARY);
            send(exchange, 200, "text/html; charset=utf-8", html);
        });
        server.createContext("/api/profile", exchange ->
            send(exchange, 200, "application/json", "{\"ok\":true}")
        );
        server.start();
        baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();

        FirefoxOptions options = new FirefoxOptions();
        options.setCapability("webSocketUrl", true);
        driver = new FirefoxDriver(options);
    }

    @AfterEach
    void stopBrowserAndServer() {
        if (driver != null) {
            driver.quit();
        }
        if (server != null) {
            server.stop(0);
        }
    }

    @Test
    void reportsOnlyTheSafeProjection() {
        List<SafeRequest> records = new CopyOnWriteArrayList<>();

        try (Network network = new Network(driver)) {
            network.onBeforeRequestSent(event -> {
                RequestData request = event.getRequest();
                if (request.getUrl().startsWith(baseUrl + "/api/profile")) {
                    records.add(SafeRequest.from(request));
                }
            });

            driver.get(baseUrl + "/");
            new WebDriverWait(driver, Duration.ofSeconds(5))
                .until(ignored -> !records.isEmpty());
        }

        SafeRequest record = records.get(0);
        String artifactLine = record.toString();

        assertEquals("GET", record.method());
        assertEquals("/api/profile", record.route());
        assertTrue(record.headers().contains(
            new SafeHeader("authorization", "[REDACTED]")
        ));
        assertTrue(record.headers().contains(
            new SafeHeader("x-request-id", "case-42")
        ));
        assertFalse(artifactLine.contains(CANARY));
        assertFalse(artifactLine.contains("alice"));

        System.out.println(artifactLine);
    }

    private static void send(
        HttpExchange exchange,
        int status,
        String contentType,
        String body
    ) throws IOException {
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().set("Content-Type", contentType);
        exchange.sendResponseHeaders(status, bytes.length);
        try (var output = exchange.getResponseBody()) {
            output.write(bytes);
        }
    }

    record SafeHeader(String name, String value) {}

    record SafeRequest(
        String requestId,
        String method,
        String route,
        Long bodySize,
        List<SafeHeader> headers
    ) {
        private static final Set<String> SENSITIVE_HEADERS = Set.of(
            "authorization",
            "cookie",
            "proxy-authorization",
            "set-cookie",
            "x-api-key"
        );

        private static final Set<String> VISIBLE_HEADERS = Set.of(
            "accept",
            "content-type",
            "x-request-id"
        );

        static SafeRequest from(RequestData request) {
            URI uri = URI.create(request.getUrl());
            String route = uri.getPath().equals("/api/profile")
                ? "/api/profile"
                : "<unclassified>";

            List<SafeHeader> safeHeaders = new ArrayList<>();
            for (Header header : request.getHeaders()) {
                String name = header.getName().toLowerCase(Locale.ROOT);
                if (SENSITIVE_HEADERS.contains(name)) {
                    safeHeaders.add(new SafeHeader(name, "[REDACTED]"));
                } else if (VISIBLE_HEADERS.contains(name)) {
                    safeHeaders.add(
                        new SafeHeader(name, header.getValue().getValue())
                    );
                }
            }

            return new SafeRequest(
                request.getRequestId(),
                request.getMethod().toUpperCase(Locale.ROOT),
                route,
                request.getBodySize(),
                List.copyOf(safeHeaders)
            );
        }
    }
}

Run that focused test before connecting the listener to a suite-wide reporter:

Shell
mvn -Dtest=RedactedBiDiCaptureTest test

A successful line should contain the request method, the route template, a request identifier, the safe correlation header, and authorization=[REDACTED]. It must not contain the canary or alice. The request identifier already supplied by the protocol is better for event correlation than a token hash. It links before-request and response events without creating a stable representation of a credential.

Notice what the callback does not do. It never logs event, request, request.getHeaders(), or an exception that contains any of those objects. It does not store RequestData for a later worker. A reviewer can follow the type boundary: Network produces a sensitive event, SafeRequest.from performs the only projection, and the collection accepts SafeRequest rather than Object.

The header policy is deliberately strict. An unknown diagnostic header disappears until someone adds it to VISIBLE_HEADERS after review. That costs convenience when a new service introduces a useful header. The alternative, retaining every unknown value, makes each new service header an unreviewed route into CI storage.

Route classification has a similar cost. A new endpoint first appears as unclassified instead of exposing its raw path. Teams with hundreds of endpoints should generate route labels from the application contract or maintain a compact route registry. They should not solve the maintenance problem by keeping arbitrary path segments.

Cover response headers, cookies, and URLs with explicit rules

A request-only redactor can pass every unit test while Set-Cookie leaks from the response listener. Another common miss is a URL sanitizer that removes access_token but leaves code, signature, or a customer email. The durable policy starts from fields whose values may be shown, not from a short list of values known to be bad today.

The Authorization reference describes that request field as carrying credentials. The Set-Cookie reference shows how one response field can include a cookie value plus attributes. Keeping the attributes while removing only the first key-value pair is fragile because extensions and quoted values complicate parsing. For an incident report, retaining the header name and replacing its whole value is usually enough.

The next utility applies one policy to request and response headers. It also normalizes a known dynamic route and shows only approved query values. A malformed URL becomes a fixed marker; the exception path never returns the original text.

Java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

import java.net.URI;
import java.util.Arrays;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;

class EvidenceRedactorTest {
    private static final String CANARY = "qa-canary-778899";

    @Test
    void treatsHeaderNamesAsCaseInsensitive() {
        assertEquals(
            new SafeField("authorization", "[REDACTED]"),
            EvidenceRedactor.header("Authorization", "Bearer " + CANARY)
        );
        assertEquals(
            new SafeField("set-cookie", "[REDACTED]"),
            EvidenceRedactor.header(
                "SeT-CoOkIe",
                "session=" + CANARY + "; HttpOnly; Secure"
            )
        );
        assertEquals(
            new SafeField("content-type", "application/json"),
            EvidenceRedactor.header("Content-Type", "application/json")
        );
    }

    @Test
    void removesDynamicPathSegmentsAndPrivateQueryValues() {
        String safe = EvidenceRedactor.url(
            "https://app.example.test/users/alice@example.test/orders"
                + "?access_token=" + CANARY + "&page=2&email=alice@example.test"
        );

        assertEquals(
            "https://app.example.test/users/{user}/orders"
                + "?access_token=[REDACTED]&page=2&email=[REDACTED]",
            safe
        );
        assertFalse(safe.contains(CANARY));
        assertFalse(safe.contains("alice"));
    }

    @Test
    void doesNotEchoAnInvalidUrlFromTheFailurePath() {
        String raw = "https://example.test/%zz?token=" + CANARY;
        String safe = EvidenceRedactor.url(raw);

        assertEquals("<invalid-url>", safe);
        assertFalse(safe.contains(CANARY));
    }

    record SafeField(String name, String value) {}

    static final class EvidenceRedactor {
        private static final Set<String> VISIBLE_HEADER_VALUES = Set.of(
            "accept",
            "content-length",
            "content-type"
        );

        private static final Set<String> VISIBLE_QUERY_VALUES = Set.of(
            "locale",
            "page",
            "sort"
        );

        static SafeField header(String rawName, String rawValue) {
            String name = rawName.toLowerCase(Locale.ROOT);
            String value = VISIBLE_HEADER_VALUES.contains(name)
                ? rawValue
                : "[REDACTED]";
            return new SafeField(name, value);
        }

        static String url(String rawUrl) {
            try {
                URI uri = URI.create(rawUrl);
                String port = uri.getPort() == -1 ? "" : ":" + uri.getPort();
                String route = classifyRoute(uri.getPath());
                String query = redactQuery(uri.getRawQuery());

                return uri.getScheme()
                    + "://"
                    + uri.getHost()
                    + port
                    + route
                    + query;
            } catch (IllegalArgumentException invalidUrl) {
                return "<invalid-url>";
            }
        }

        private static String classifyRoute(String path) {
            if (path.matches("/users/[^/]+/orders")) {
                return "/users/{user}/orders";
            }
            return Set.of("/health", "/login", "/search").contains(path)
                ? path
                : "<unclassified>";
        }

        private static String redactQuery(String rawQuery) {
            if (rawQuery == null || rawQuery.isBlank()) {
                return "";
            }

            String safeQuery = Arrays.stream(rawQuery.split("&", -1))
                .map(EvidenceRedactor::redactParameter)
                .collect(Collectors.joining("&"));
            return "?" + safeQuery;
        }

        private static String redactParameter(String pair) {
            int separator = pair.indexOf('=');
            String name = separator < 0 ? pair : pair.substring(0, separator);
            String value = separator < 0 ? "" : pair.substring(separator + 1);
            String normalizedName = name.toLowerCase(Locale.ROOT);

            if (VISIBLE_QUERY_VALUES.contains(normalizedName)) {
                return name + "=" + value;
            }
            return name + "=[REDACTED]";
        }
    }
}

This policy is conservative but not universal. It assumes query parameter names may be visible. If names can contain user-supplied or regulated data in your product, replace every unapproved name with a generic redacted-param label too. It also preserves safe query values without decoding them. Review encoded and repeated parameters against your application's router before adopting it.

The code classifies only routes the test estate knows. A path such as /reset/secret-value becomes unclassified, so the secret does not enter the artifact. That choice reduces detail during early diagnosis. It is still better than allowing an unfamiliar endpoint to silently widen the log schema.

Response listeners should call the same header function for every item returned by ResponseData.getHeaders(). Do not build a special Set-Cookie parser unless the test genuinely needs a cookie attribute. If the assertion cares whether a secure cookie was issued, use WebDriver's cookie API to assert the cookie behavior in the test and record only a boolean such as secureCookiePresent in the artifact.

Bodies require another redactor. Neither example reads one, and that omission is intentional. JSON, form data, multipart uploads, and GraphQL each need format-aware handling. Labeling a header and URL policy as a full network sanitizer would create a dangerous gap.

Prove the uploaded artifact, not only the helper

A green redactor unit test proves the helper for the inputs it received. It does not prove that the helper owns every path to storage. A debug statement can serialize the raw event first. A failure handler can attach an object dump later. A retry listener can preserve the original exception, and an HTTP archive or browser trace can independently retain request data.

Use synthetic canaries to test the complete pipeline. The canary must be unique to the run, harmless outside the test environment, and easy to revoke. Put it in every credential channel the fixture supports, then inspect the final files that CI is about to upload. Do not print the matching line when the gate fails because doing so repeats the leak in job output.

This diagnostic gate scans one final artifact without echoing a match. It also rejects an empty capture that contains no redaction marker:

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

artifact=${1:?Usage: scan-network-artifact.sh <artifact>}
: "${QA_CANARY_TOKEN:?QA_CANARY_TOKEN is required}"
test -f "$artifact"

if grep -Fq -- "$QA_CANARY_TOKEN" "$artifact"; then
  printf 'secret scan failed for %s, value withheld\n' "$artifact" >&2
  exit 1
fi

if ! grep -Fq -- '[REDACTED]' "$artifact"; then
  printf 'capture gap in %s, no redaction marker found\n' "$artifact" >&2
  exit 2
fi

printf 'secret scan passed for %s\n' "$artifact"

This small Java program fails on an exact canary match while withholding the value. It also requires a REDACTED marker, which catches the opposite failure where capture stopped working and produced an empty artifact.

Java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public final class SecretLeakGate {
    public static void main(String[] args) throws IOException {
        if (args.length != 1) {
            throw new IllegalArgumentException(
                "Usage: SecretLeakGate <network-artifact>"
            );
        }

        String canary = System.getenv("QA_CANARY_TOKEN");
        if (canary == null || canary.isBlank()) {
            throw new IllegalStateException("QA_CANARY_TOKEN is not set");
        }

        Path artifactPath = Path.of(args[0]);
        String artifact = Files.readString(
            artifactPath,
            StandardCharsets.UTF_8
        );

        if (artifact.contains(canary)) {
            System.err.println(
                "SECRET LEAK: QA canary found in "
                    + artifactPath
                    + " (value withheld)"
            );
            System.exit(1);
        }

        if (!artifact.contains("[REDACTED]")) {
            System.err.println(
                "CAPTURE GAP: no redaction marker in " + artifactPath
            );
            System.exit(2);
        }

        System.out.println("Secret scan passed for " + artifactPath);
    }
}

Compile it and point it at the same file the CI upload step uses:

Shell
javac SecretLeakGate.java
QA_CANARY_TOKEN='qa-canary-778899' \
  java SecretLeakGate build/reports/network-events.jsonl

The expected failure contains a path and the phrase value withheld. It must never contain the canary. Exit code 1 means retention failed. Exit code 2 means the expected sanitized evidence is absent, so the run cannot claim redaction passed.

An exact scanner will not catch transformed secrets. Basic credentials may appear as Base64. A URL encoder can change punctuation. A report may split a value across JSON escapes, compress the file, or store it in a screenshot. Add known representations of your synthetic canaries, scan archives after extraction in a controlled directory, and inspect each artifact format through its own reader. Keep failure messages content-free.

Positive assertions matter as much as absence. A file with zero events contains no secrets but offers no evidence that the listener ran. Check the expected route label, method, status, event count, test identity, and redaction marker. This distinguishes a working sanitizer from a disabled capture path.

Do not use a production token as the scanner needle. Reading the artifact into memory and comparing it would spread the real secret into another process. A purpose-made canary lets the gate prove propagation controls without handling a live credential.

Repository search can locate likely artifact files without echoing their contents. A command such as the following prints filenames only:

Shell
rg -l -i 'authorization|set-cookie|access[_-]?token|api[_-]?key' build/reports

Treat that command as triage, not proof. The presence of authorization=[REDACTED] is expected, while an unlabelled token might not match any familiar name. The canary gate and structured assertions decide the result.

Tell a redaction defect from a missing BiDi event

The same empty network attachment can mean two opposite things. The sanitizer may have removed too much, or Selenium may never have observed the target request. Changing redaction rules before separating those cases can turn a connection problem into a privacy regression.

Track counts, not payloads, at each boundary. One useful diagnostic record looks like this:

Example
bidi.before_request.total=18
bidi.before_request.target=1
safe_request.created=1
safe_request.written=1
artifact.canary_matches=0
artifact.redaction_markers=1

These labels are application-owned counters, not Selenium configuration keys. They reveal movement through the pipeline without retaining event values. The sequence also gives each failure a narrow owner.

If total is zero, inspect the subscription and session first. The listener must be registered before the navigation or click that sends the request. Confirm the browser and Selenium versions support the network event used by the test. Check the effective session capabilities and Grid path for the BiDi connection. A missing event at this point is not evidence that redaction removed a value.

If total is positive but target is zero, the filter may be wrong. Redirects can change the host or path. A service worker or cache can alter which network activity occurs, and cross-origin redirects normally strip Authorization. Compare method, classified route, redirect count, and event timing through safe fields. Do not temporarily print every raw URL in shared CI to discover the new endpoint.

If target is one but safe_request.created is zero, the projection threw or rejected the route. Preserve the exception class and a fixed policy-rule identifier, not the raw event. The invalid-URL example returns a fixed marker because exception messages can include their input. A new route becoming unclassified should be an observable policy event, not a reason to fall back to the full URL.

If a safe record was written but the canary still appears, another sink owns the leak. Search reporter attachments, framework debug logs, retry summaries, console capture, HTTP archives, browser traces, and exception serialization. Compare artifact creation timestamps with the listener timestamp. The safe console line does not clear a raw JSON attachment created milliseconds earlier.

Request and response events can also be confused. Authorization and Cookie are request concerns. Set-Cookie is a response concern. A before-request listener cannot prove the response path is safe, and a response-completed listener may never run when a fetch fails. Count the two event types separately and give each a redaction test.

Parallel execution adds one more near-miss. A static mutable list can combine events from different drivers, causing the scanner to report a canary under the wrong test. Keep the Network lifetime, safe-event collection, test identity, and artifact writer scoped to one driver session. If the suite aggregates later, aggregate safe records only.

Here is a practical interpretation table:

EvidenceLikely causeNext check
No BiDi events of any kindSubscription or session problemListener order, supported browser, effective capabilities
Events exist, target count is zeroFilter, redirect, cache, or service worker differenceSafe method, route label, redirect count, timing
Target count is positive, no safe record existsProjection rejected or threwPolicy rule identifier and exception class
Safe record exists, marker is absentOver-filtering or wrong artifactExpected field-presence assertion and upload path
Safe record exists, canary is presentA second raw sink bypassed the projectionReporter, trace, archive, retry, and exception attachments
Request artifact is clean, response artifact leaksResponse policy gapResponse listener and Set-Cookie handling

Keep the first failing attempt. A passing retry may use a fresh token, take another redirect path, or hit a warm cache. Overwriting the first artifact destroys the only evidence that identifies the bypass. Give every attempt its own safe artifact and scan each one before aggregation.

Roll the policy through an existing suite

Replacing println calls is the easy part. Established suites often have several consumers of the same event: a console formatter, a report attachment, a failure analyzer, an in-memory timeline, and a custom upload step. A safe rollout changes the type crossing that boundary, then proves that old consumers cannot receive raw data.

Start with an inventory of sinks. Search for Network listeners, RequestData and ResponseData usage, object serializers, attachment APIs, HTTP archive creation, trace settings, and debug logging around failures. Record who owns each sink and how long its output is retained. Avoid opening existing artifacts in a terminal if they may already contain credentials; use filename searches and a restricted incident process.

Define a versioned evidence schema before migrating consumers. A compact request record might contain schema version, test and attempt identifiers, BiDi request identifier, method, route label, selected sizes, selected timings, safe header-presence markers, and a test-owned correlation value. A response record can add status, MIME type, cache state, and body size. None of those fields requires a credential.

Add policy tests before switching the producer. Cover mixed-case header names, duplicate headers, malformed URLs, repeated query parameters, encoded values, dynamic paths, redirects, request cookies, Set-Cookie responses, and unknown fields. Use separate canaries for bearer tokens, cookies, one-time codes, and personal identifiers so the failing gate identifies a class without exposing a value.

Move projection into the listener next. Change downstream method signatures from Object, RequestData, or ResponseDetails to the safe record types. This compile-time friction is useful. A generic serializer makes it too easy for a future contributor to pass the protocol object around the redactor.

For a short migration window, compare diagnostic usefulness in a restricted local fixture. Do not upload raw and sanitized production-like events side by side. Ask specific questions: Can the safe record identify the failed endpoint? Can it connect request and response? Can it distinguish a 401 from a transport error? Can it show that Authorization was present without showing the value? Add only the minimum field needed to answer a question that the team actually uses.

Then turn on artifact gates in non-blocking mode for synthetic test jobs. A warning period finds forgotten sinks and unsupported formats without teaching teams to bypass a suddenly failing main pipeline. Set a date and owner for making the gate blocking. An indefinite warning is documentation, not a control.

Once the gate becomes blocking, these GitHub Actions steps preserve failed test reports only after the exact canary scan succeeds:

YAML
- name: Run the redaction fixture
  id: redaction_test
  continue-on-error: true
  env:
    QA_CANARY_TOKEN: qa-canary-token-not-a-real-secret
  run: mvn -B -Dtest=RedactedBiDiCaptureTest test

- name: Scan the final report files
  id: secret_scan
  if: ${{ always() }}
  env:
    QA_CANARY_TOKEN: qa-canary-token-not-a-real-secret
  run: |
    test -d target/surefire-reports
    if grep -R -Fq -- "$QA_CANARY_TOKEN" target/surefire-reports; then
      printf 'secret scan failed, value withheld\n' >&2
      exit 1
    fi
    grep -R -Fq -- '[REDACTED]' target/surefire-reports

- name: Upload sanitized network evidence
  if: ${{ always() && steps.secret_scan.outcome == 'success' }}
  uses: actions/upload-artifact@v4
  with:
    name: redacted-network-evidence-${{ github.run_attempt }}
    path: target/surefire-reports
    if-no-files-found: error

- name: Restore the test failure
  if: ${{ steps.redaction_test.outcome == 'failure' }}
  run: exit 1

Once the gate blocks uploads, remove the raw formatter and old artifact schema. Check retry jobs, scheduled suites, local failure bundles, and Grid diagnostics as separate paths. Rotate synthetic credentials regularly. If a live credential is found in historical output, revoke it first, then follow the organization's incident and artifact-retention process.

The costs are concrete:

  • Less raw context means an engineer may need a targeted local rerun to inspect a new header or route.
  • Route and field allowlists require review whenever the application contract changes.
  • Canary jobs add browser time and artifact-scanning time to CI.
  • Per-session collectors use more objects than a single static logger, though they avoid cross-test contamination.
  • Versioned safe schemas force report consumers to handle migrations instead of accepting arbitrary maps.
  • Selenium's Java Network module is marked Beta, so dependency upgrades need focused compilation and behavior checks.

Those costs are usually smaller than revoking credentials, purging artifacts, and investigating who downloaded a report. Still, measure them. If redaction adds enough callback work to delay a high-volume test, keep the callback projection small and move only safe records to a worker. Never move the raw event for performance convenience.

Know when redaction is the wrong control

Skip header capture when the test only needs to know that a request happened. Method, route, status, and a request identifier can often diagnose a failing UI flow. Subscribing to rich fields and then deleting nearly all of them creates complexity without adding evidence.

Do not capture production or customer credentials in a shared automation environment. A perfect reporter policy cannot prevent a heap dump, debugger, compromised worker, third-party agent, or browser-level artifact from seeing values available to the process. Use isolated synthetic accounts with short-lived, narrowly scoped credentials.

Avoid token hashes as a default compromise. A stable hash links activity across artifacts and environments. Low-entropy secrets such as short one-time codes can be guessed. BiDi request identifiers and harmless correlation headers already provide safer ways to connect records within one run.

Do not apply this header utility to bodies and call the job complete. Response bodies may contain access tokens and personal data even when every header is clean. Body capture needs a content-type-specific parser, size limits, failure behavior, and its own canaries. When that investment is not justified, record body size and MIME type instead of body content.

Protocol conformance tests are another special case. A test that verifies the exact bytes of a Cookie or Authorization header cannot assert against a redacted value. Keep its comparison in memory, use a synthetic credential, avoid attaching the actual value, and emit only pass or fail plus safe context. If an exact-value failure must be investigated, reproduce it in a restricted environment outside the normal artifact pipeline.

Browser traces and HTTP archives need independent review. Sanitizing a Selenium listener does not rewrite a trace produced by the browser, a proxy, or another library. Disable those captures for sensitive scenarios, configure their own supported controls, or keep them out of shared storage. One clean network-events file does not certify the rest of the job.

Finally, stop capture when the team cannot name who maintains the policy. Secret-bearing fields evolve as authentication and routing change. An abandoned allowlist either hides too much to help or gets weakened during the next incident. Ownership, canary coverage, and a blocking final-artifact scan are part of the implementation, not optional paperwork.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official w3.org reference

    w3.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Does Selenium automatically redact Authorization headers from BiDi events?

No. Network events can expose request headers to the listener, so treat the event object as sensitive input. Project it into a safe record before a logger, reporter, assertion message, or artifact writer sees it.

Where should I redact Selenium network events?

Transform each event inside the listener callback and pass only the sanitized record downstream. Redacting the final console line is too late if a queue, debug logger, or report attachment already retained the original event.

Should I hash bearer tokens so requests can still be correlated?

A hash is often a poor substitute for removal because stable hashes enable correlation and low-entropy values can be guessed. Prefer the BiDi request identifier or a test-owned correlation header that contains no credential.

How can CI prove a network artifact contains no secrets?

Seed the test environment with unique canary credentials, exercise every artifact path, and fail when the exact canary appears. Pair that negative check with positive assertions for the redaction marker and useful safe fields.

Why did a token leak when my redactor unit tests pass?

Another sink is probably serializing the raw event before or after your helper runs. Count captured events and safe records separately, then scan the final uploaded files rather than trusting console output.