PRACTICAL GUIDE / Selenium WebDriver BiDi network security testing

Catch browser security regressions with Selenium BiDi

Use Selenium BiDi events to verify security headers, redirect boundaries, and HTTP authentication while keeping credentials out of test artifacts.

By The Testing AcademyUpdated August 4, 202628 min read
All field guides
In this guide7 sections
  1. Why a green page can still violate the network contract
  2. Prove which security headers reached the browser
  3. Follow redirects across origin boundaries
  4. Exercise the authentication challenge itself
  5. Distinguish a policy failure from its near-misses
  6. Roll the checks into CI without leaking secrets
  7. Know when BiDi is the wrong layer

What you will learn

  • Why a green page can still violate the network contract
  • Prove which security headers reached the browser
  • Follow redirects across origin boundaries
  • Exercise the authentication challenge itself

A dashboard test reaches the signed-in page even after a gateway drops the Content-Security-Policy header. The locator assertions stay green, but the browser received a weaker response than the team approved. BiDi network events let the test preserve that wire-level evidence without replacing the user journey with an HTTP client.

Why a green page can still violate the network contract

Most UI checks answer a product question: did the browser display the account page, enable the expected button, or complete the checkout? Security headers and redirect rules sit one layer lower. A page can render perfectly after losing clickjacking protection, relaxing its referrer policy, or sending a bearer token to the wrong origin. Nothing in a normal element assertion notices those changes.

WebDriver BiDi adds an event stream beside the familiar WebDriver commands. In Selenium's Java binding, a Network object can listen for a request before it is sent, a response when its headers arrive, a response after its body completes, an authentication challenge, or a fetch error. These records come from the browser session that performs the UI action. That connection matters. An API call made by a separate test client can prove what the origin returned to that client, but it cannot prove what the automated browser received after its proxy, cache, redirect handling, and browser-specific behavior were involved.

The event phases answer different questions:

  • onBeforeRequestSent identifies the outgoing method, URL, request id, browsing context, navigation id, and redirect count. Use it to establish where a navigation is about to go.
  • onResponseStarted arrives after response headers are available and before the full body has finished. It is the useful point for header and redirect assertions because the test does not need to wait for a large body.
  • onResponseCompleted confirms the full response arrived. It also exposes the final status, headers, protocol, byte counts, and cache indication through the response data.
  • onAuthRequired represents a browser authentication challenge. It is not a synonym for every 401 response.
  • onFetchError identifies a request that ended as a network error. It helps distinguish a missing response from a response that violates policy.

A listener observes. An intercept changes control flow. Calling addIntercept for a phase causes matching requests to pause at that phase until the test continues, fails, or supplies a response. Do not add an intercept merely to collect headers. It adds a failure path that the product does not have. Authentication is different because continueWithAuth can only act on a request blocked at the authentication phase. The worked authentication example therefore installs a narrow AUTH_REQUIRED intercept, while the header and redirect examples only subscribe to events.

Registration order is easy to get wrong. Attach listeners before the action that creates the traffic. If driver.get runs first, a fast local response may complete before the listener exists. Waiting longer afterward will never recover an event that was not subscribed to. A timeout in that situation diagnoses the harness, not the application.

This runnable JUnit test registers the listener first, waits for the exact document response, and enforces complete CSP directives rather than a loose substring such as the header name:

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

import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

class CspContractTest {
    @Test
    void browserReceivesTheApprovedCspDirectives() throws Exception {
        String target = System.getenv("SECURITY_CHECK_URL");
        if (target == null || target.isBlank()) {
            throw new IllegalStateException("SECURITY_CHECK_URL is required");
        }

        FirefoxOptions options = new FirefoxOptions();
        options.setCapability("webSocketUrl", true);
        WebDriver driver = new FirefoxDriver(options);
        CompletableFuture<ResponseDetails> document = new CompletableFuture<>();

        try (Network network = new Network(driver)) {
            network.onResponseStarted(event -> {
                if (target.equals(event.getResponseData().getUrl())) {
                    document.complete(event);
                }
            });

            driver.get(target);
            ResponseDetails event = document.get(10, TimeUnit.SECONDS);
            assertEquals(200, event.getResponseData().getStatus());

            String csp = event.getResponseData().getHeaders().stream()
                    .filter(header -> header.getName().equalsIgnoreCase(
                            "Content-Security-Policy"))
                    .map(header -> header.getValue().getValue())
                    .findFirst()
                    .orElseThrow(() -> new AssertionError(
                            "Content-Security-Policy is missing"));

            Set<String> directives = Arrays.stream(csp.split(";"))
                    .map(String::trim)
                    .filter(value -> !value.isEmpty())
                    .collect(Collectors.toSet());

            assertTrue(directives.contains("default-src 'self'"));
            assertTrue(directives.contains("object-src 'none'"));
            assertTrue(directives.contains("frame-ancestors 'none'"));
        } finally {
            driver.quit();
        }
    }
}

Filtering matters just as much. A modern page can request fonts, analytics scripts, images, source maps, API resources, and a favicon during one navigation. Completing a future with the first response event makes the test depend on resource timing. Match the exact target URL or a deliberately narrow route, then assert the browsing context or navigation id where that distinction matters. A response from the correct host is still not necessarily the main document.

Redirects need one more field. The WebDriver BiDi specification gives a redirected request the same request id as the request that initiated the chain, while redirectCount advances for each hop. That lets the test separate a real HTTP redirect from a later JavaScript navigation. A new request id with redirectCount zero is evidence of a separate request, even if the two URLs appear next to each other in a timestamped log.

The Java Network module is marked Beta in Selenium's API documentation. That does not make it unusable, but it changes how a team should adopt it. Pin the Selenium version, compile the test helpers as part of the normal build, and review API changes during upgrades. The examples below use Firefox with the webSocketUrl capability because that is the setup shown in Selenium's current Java network examples. A Grid run also needs the BiDi WebSocket connection to reach the browser node. If the browser journey succeeds but no subscribed event ever arrives, fail the instrumentation explicitly instead of reporting a missing security header.

Security evidence can itself become a vulnerability. Request headers may include Authorization, cookies, CSRF values, and tracing identifiers. Redirect URLs may carry authorization codes or reset tokens. Response headers can contain Set-Cookie. Collect only the fields needed for the assertion. A safe default is method, redacted URL, request id, redirect count, response status, cache state, and an allowlist of security header names. Raw event serialization does not belong in a general CI attachment.

Prove which security headers reached the browser

A reverse proxy change often breaks security policy while leaving application code untouched. One environment may add CSP at the edge, another may serve it from the application, and a cache may hold yesterday's response. The test needs to name the route and the required policy, not make a vague assertion that "some security headers exist."

This JUnit 5 example starts a real local HTTP server, launches Firefox, listens for the document response, and checks three headers. It is self-contained apart from the Selenium Java and JUnit Jupiter dependencies. The local endpoint keeps the example deterministic and avoids sending test traffic or credentials to a public service.

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

import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

class SecurityHeaderTest {
    private HttpServer server;
    private WebDriver driver;

    @BeforeEach
    void startServerAndBrowser() throws IOException {
        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/account", exchange -> {
            byte[] body = """
                    <!doctype html>
                    <html><body><h1 id="page-title">Account</h1></body></html>
                    """.getBytes(StandardCharsets.UTF_8);

            exchange.getResponseHeaders().add(
                    "Content-Security-Policy",
                    "default-src 'self'; object-src 'none'; frame-ancestors 'none'");
            exchange.getResponseHeaders().add("X-Content-Type-Options", "nosniff");
            exchange.getResponseHeaders().add(
                    "Referrer-Policy", "strict-origin-when-cross-origin");
            exchange.getResponseHeaders().add(
                    "Content-Type", "text/html; charset=utf-8");
            exchange.sendResponseHeaders(200, body.length);
            try (OutputStream output = exchange.getResponseBody()) {
                output.write(body);
            }
        });
        server.start();

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

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

    @Test
    void browserReceivesTheApprovedSecurityHeaders() throws Exception {
        String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/account";
        CompletableFuture<ResponseDetails> documentResponse = new CompletableFuture<>();

        try (Network network = new Network(driver)) {
            network.onResponseStarted(event -> {
                if (url.equals(event.getResponseData().getUrl())) {
                    documentResponse.complete(event);
                }
            });

            driver.get(url);
            ResponseDetails event = documentResponse.get(5, TimeUnit.SECONDS);
            Map<String, String> headers = headerMap(event);

            assertEquals(200, event.getResponseData().getStatus());
            assertFalse(event.getResponseData().isFromCache());
            assertEquals(
                    "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
                    headers.get("content-security-policy"));
            assertEquals("nosniff", headers.get("x-content-type-options"));
            assertEquals(
                    "strict-origin-when-cross-origin",
                    headers.get("referrer-policy"));
            assertEquals(
                    "Account",
                    driver.findElement(By.id("page-title")).getText());
        }
    }

    private static Map<String, String> headerMap(ResponseDetails event) {
        return event.getResponseData().getHeaders().stream()
                .collect(Collectors.toMap(
                        header -> header.getName().toLowerCase(Locale.ROOT),
                        header -> header.getValue().getValue(),
                        (first, ignored) -> first));
    }
}

The final assertion deliberately retains a page-level outcome. In this fixture the document has no title, so it checks the heading. In a production test, make that assertion direct and specific to the user journey. The network record proves the policy header. The DOM record proves the page still works under that policy. Neither claim substitutes for the other.

Exact CSP string equality is appropriate only when the route has a fixed policy. A nonce-bearing policy changes on every response, and directive order may be controlled by a gateway. For those routes, parse the semicolon-separated directives and assert the required directive values. Do not weaken the test to a contains check for the word "Content-Security-Policy." A header such as default-src * contains a CSP header and still defeats the intended restriction.

The failure should print the URL, missing header name, status, and cache state, but not dump every header. A small assertion helper can produce evidence like this:

Example
java.lang.AssertionError: security-header mismatch
url=http://127.0.0.1:49152/account
status=200 fromCache=false
expected content-security-policy=<approved policy>
actual content-security-policy=<missing>

That output points to a response-policy regression. By contrast, a TimeoutException from documentResponse.get means the expected event never matched. Check listener timing, the actual final URL, the browser's BiDi connection, and route filtering before filing a header defect.

Cache state is a useful near-miss. If isFromCache is true and an independent request to the origin shows the current header, the browser may be testing a stale object. Reproduce in a fresh browser session and preserve both observations. Do not automatically disable caching in every security check. Doing so makes the test blind to a production path where users receive cached responses. Keep one deterministic origin-policy check and, when cache behavior is part of the risk, a separate test that intentionally exercises the cache.

Strict-Transport-Security needs another boundary. Browsers ignore an HSTS header received over plain HTTP, so asserting its presence on this local HTTP fixture would teach the wrong lesson. Check HSTS on an HTTPS environment with a valid certificate and an agreed host policy. Also remember that a browser with remembered HSTS state can upgrade an HTTP URL before the request leaves. The observed request URL and redirect sequence tell you whether the server issued a redirect or the browser upgraded internally.

Follow redirects across origin boundaries

Redirect bugs are rarely visible in the destination page. A user clicks "Continue," lands on the expected identity provider, and the UI test passes. The dangerous detail is in the URL between those states: a session token, password-reset token, or authorization code crossed into an origin that was never meant to receive it.

An origin consists of scheme, host, and port. Two services on 127.0.0.1 with different ports are therefore cross-origin, which makes a deterministic local reproduction possible. The next example creates an application server and a receiver server. The application intentionally puts a dummy session_token in the Location target. The detector catches that violation and redacts the value before constructing its failure message.

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

import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
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.BeforeRequestSent;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

class RedirectBoundaryTest {
    private static final Pattern SENSITIVE_QUERY_KEY = Pattern.compile(
            "(?i)(^|&)(token|access_token|session_token|code)=");

    private HttpServer application;
    private HttpServer receiver;
    private WebDriver driver;
    private String startUrl;
    private String targetUrl;

    @BeforeEach
    void startServersAndBrowser() throws IOException {
        application = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        receiver = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);

        targetUrl = "http://127.0.0.1:" + receiver.getAddress().getPort()
                + "/landing?session_token=abc123";
        startUrl = "http://127.0.0.1:" + application.getAddress().getPort()
                + "/continue";

        application.createContext("/continue", exchange -> {
            exchange.getResponseHeaders().add("Location", targetUrl);
            exchange.sendResponseHeaders(302, -1);
            exchange.close();
        });
        receiver.createContext("/landing", exchange -> {
            byte[] body = "<h1 id=\"result\">Receiver</h1>"
                    .getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().add(
                    "Content-Type", "text/html; charset=utf-8");
            exchange.sendResponseHeaders(200, body.length);
            try (OutputStream output = exchange.getResponseBody()) {
                output.write(body);
            }
        });
        application.start();
        receiver.start();

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

    @AfterEach
    void stopEverything() {
        if (driver != null) {
            driver.quit();
        }
        if (application != null) {
            application.stop(0);
        }
        if (receiver != null) {
            receiver.stop(0);
        }
    }

    @Test
    void detectsASecretCrossingAnOriginBoundary() throws Exception {
        CompletableFuture<BeforeRequestSent> firstRequest = new CompletableFuture<>();
        CompletableFuture<BeforeRequestSent> redirectedRequest = new CompletableFuture<>();
        CompletableFuture<ResponseDetails> redirectResponse = new CompletableFuture<>();

        try (Network network = new Network(driver)) {
            network.onBeforeRequestSent(event -> {
                String url = event.getRequest().getUrl();
                if (startUrl.equals(url)) {
                    firstRequest.complete(event);
                } else if (targetUrl.equals(url)) {
                    redirectedRequest.complete(event);
                }
            });
            network.onResponseStarted(event -> {
                if (startUrl.equals(event.getResponseData().getUrl())
                        && event.getResponseData().getStatus() == 302) {
                    redirectResponse.complete(event);
                }
            });

            driver.get(startUrl);

            BeforeRequestSent first = firstRequest.get(5, TimeUnit.SECONDS);
            BeforeRequestSent next = redirectedRequest.get(5, TimeUnit.SECONDS);
            ResponseDetails redirect = redirectResponse.get(5, TimeUnit.SECONDS);

            assertEquals(
                    first.getRequest().getRequestId(),
                    next.getRequest().getRequestId());
            assertEquals(0, first.getRedirectCount());
            assertEquals(1, next.getRedirectCount());
            assertEquals(302, redirect.getResponseData().getStatus());
            assertNotEquals(origin(startUrl), origin(targetUrl));

            AssertionError finding = assertThrows(
                    AssertionError.class,
                    () -> enforceNoSecretInCrossOriginHop(startUrl, targetUrl));
            assertTrue(finding.getMessage().contains("session_token=<redacted>"));
        }
    }

    private static void enforceNoSecretInCrossOriginHop(String from, String to) {
        URI target = URI.create(to);
        String query = target.getRawQuery();
        boolean crossedOrigin = !origin(from).equals(origin(to));

        if (crossedOrigin
                && query != null
                && SENSITIVE_QUERY_KEY.matcher(query).find()) {
            throw new AssertionError(
                    "sensitive query crossed origin: " + redact(to));
        }
    }

    private static String origin(String value) {
        URI uri = URI.create(value);
        String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
        int port = uri.getPort();
        if (port == -1 && "http".equals(scheme)) {
            port = 80;
        } else if (port == -1 && "https".equals(scheme)) {
            port = 443;
        }
        return scheme
                + "://"
                + uri.getHost().toLowerCase(Locale.ROOT)
                + ":"
                + port;
    }

    private static String redact(String value) {
        return value.replaceAll(
                "(?i)(token|access_token|session_token|code)=([^&]*)",
                "$1=<redacted>");
    }
}

The test uses assertThrows because its local fixture is deliberately unsafe and the example must prove the detector recognizes it. In an application gate, call enforceNoSecretInCrossOriginHop directly for every hop. The secure test then passes silently and the first unsafe redirect fails with this controlled message:

Example
java.lang.AssertionError: sensitive query crossed origin:
http://127.0.0.1:53791/landing?session_token=<redacted>

The origin helper normalizes omitted HTTP and HTTPS ports, so a default port and its explicit form compare as the same origin.

Never insert the unredacted target into an assertion message first and sanitize the CI attachment later. Test runners, console collectors, and retry plugins may copy the exception before an artifact filter sees it. Redaction belongs at the point where evidence becomes text.

The shared request id and increasing redirect count are the decisive evidence for an HTTP redirect chain. If the target event has a different request id and redirectCount remains zero, investigate a form submission, meta refresh, JavaScript assignment, or a second user action. Those paths may still cross an origin unsafely, but calling them a server redirect sends the fix to the wrong team.

Status also changes the diagnosis. A 302 with Location is a redirect response. A 200 page that runs script to assign window.location is not. A 401 followed by a navigation to /login may be application logic rather than browser authentication. Preserve the response status beside the next request instead of reconstructing the story from URLs alone.

Query-key checks need a product-specific allowlist and denylist. The sample flags code because authorization codes are sensitive in many login flows, but some protocols intentionally place a short-lived code in a redirect URI registered to the same client. The security requirement should state which origin is allowed to receive it, not ban the word code everywhere. URL fragments need separate thought: fragments are not sent in the HTTP request, although page script at the destination can read them. Network events alone cannot prove what that script later does with the fragment.

A Location header can be relative. The browser's next beforeRequestSent event contains the resolved absolute URL, so origin comparison should use that request rather than hand-building a URL from the raw header. That is another reason to pair the redirect response with the following request event.

Exercise the authentication challenge itself

A test that opens a protected URL with credentials embedded in a URL is both brittle and unsafe. Browser behavior around that URL form has changed, reports tend to print it, and the navigation no longer proves the browser handled the challenge expected by the server. A BiDi authentication intercept lets the server issue its real 401 challenge and lets the browser respond through the protocol.

The following fixture implements HTTP Basic authentication locally. The first request receives WWW-Authenticate with a named realm. The listener records the challenge, supplies synthetic credentials, and waits for the final 200 response. The intercept is limited by protocol, host, port, and path.

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

import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.UsernameAndPassword;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.bidi.network.AddInterceptParameters;
import org.openqa.selenium.bidi.network.AuthChallenge;
import org.openqa.selenium.bidi.network.InterceptPhase;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.bidi.network.UrlPattern;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;

class BasicAuthenticationTest {
    private static final String USERNAME = "qa-user";
    private static final String PASSWORD = "correct-horse";

    private HttpServer server;
    private WebDriver driver;
    private String protectedUrl;

    @BeforeEach
    void startServerAndBrowser() throws IOException {
        String expectedAuthorization = "Basic " + Base64.getEncoder().encodeToString(
                (USERNAME + ":" + PASSWORD).getBytes(StandardCharsets.UTF_8));

        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/protected", exchange -> {
            String supplied = exchange.getRequestHeaders().getFirst("Authorization");
            if (!expectedAuthorization.equals(supplied)) {
                byte[] body = "Authentication required"
                        .getBytes(StandardCharsets.UTF_8);
                exchange.getResponseHeaders().add(
                        "WWW-Authenticate", "Basic realm=\"qa-admin\"");
                exchange.sendResponseHeaders(401, body.length);
                try (OutputStream output = exchange.getResponseBody()) {
                    output.write(body);
                }
                return;
            }

            byte[] body = "<p id=\"result\">Protected content</p>"
                    .getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().add(
                    "Content-Type", "text/html; charset=utf-8");
            exchange.sendResponseHeaders(200, body.length);
            try (OutputStream output = exchange.getResponseBody()) {
                output.write(body);
            }
        });
        server.start();

        protectedUrl = "http://127.0.0.1:" + server.getAddress().getPort()
                + "/protected";

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

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

    @Test
    void handlesTheExpectedBasicAuthenticationChallenge() throws Exception {
        int port = server.getAddress().getPort();
        UrlPattern protectedRoute = new UrlPattern()
                .protocol("http")
                .hostname("127.0.0.1")
                .port(Integer.toString(port))
                .pathname("/protected");

        CompletableFuture<ResponseDetails> challengeEvent = new CompletableFuture<>();
        CompletableFuture<ResponseDetails> completedResponse = new CompletableFuture<>();

        try (Network network = new Network(driver)) {
            String intercept = network.addIntercept(
                    new AddInterceptParameters(InterceptPhase.AUTH_REQUIRED)
                            .urlPattern(protectedRoute));
            try {
                network.onAuthRequired(event -> {
                    if (protectedUrl.equals(event.getResponseData().getUrl())) {
                        challengeEvent.complete(event);
                        network.continueWithAuth(
                                event.getRequest().getRequestId(),
                                new UsernameAndPassword(USERNAME, PASSWORD));
                    }
                });
                network.onResponseCompleted(event -> {
                    if (protectedUrl.equals(event.getResponseData().getUrl())
                            && event.getResponseData().getStatus() == 200) {
                        completedResponse.complete(event);
                    }
                });

                driver.get(protectedUrl);

                ResponseDetails challenged = challengeEvent.get(5, TimeUnit.SECONDS);
                ResponseDetails completed = completedResponse.get(5, TimeUnit.SECONDS);
                AuthChallenge challenge = challenged.getResponseData()
                        .getAuthChallenge()
                        .orElseThrow();

                assertEquals(401, challenged.getResponseData().getStatus());
                assertEquals("Basic", challenge.getScheme());
                assertEquals("qa-admin", challenge.getRealm());
                assertEquals(200, completed.getResponseData().getStatus());
                assertEquals(
                        "Protected content",
                        driver.findElement(By.id("result")).getText());
            } finally {
                network.removeIntercept(intercept);
            }
        }
    }
}

Four facts make this more than a login success check. The server first returned 401, the challenge scheme was Basic, the realm was qa-admin, and the protected document completed with 200 after credentials were supplied. A page assertion then proves the browser rendered the expected protected content.

Use synthetic credentials for a local fixture. In an environment test, obtain the value through the organization's existing secret mechanism and keep it out of the test name, parameter display, exception, screenshot metadata, and URL. Do not record the Authorization request header to prove credentials were used. The challenge event, final status, and protected outcome provide enough evidence without copying the secret.

Repeated authRequired events usually point to wrong credentials, an unexpected realm, or an intercept that covers more hosts than intended. Do not keep supplying the same password indefinitely. Count challenge events per request and cancel after the expected attempt so the test terminates with a bounded diagnostic. A browser dialog that remains open after continueWithAuth is evidence that the command did not act on the blocked request, often because the intercept or event listener did not match the same route.

A JSON API returning 401 without WWW-Authenticate is a near-miss, not this mechanism. It may represent an expired bearer token handled by application JavaScript. Assert that API contract through its response and UI state. Do not force it into continueWithAuth. OAuth and form-based login are also different flows. They need browser interactions, redirect validation, and provider-specific assertions rather than HTTP authentication credentials.

Interception has a real cost. The request pauses while the callback runs. A slow secret lookup, blocked executor, or exception inside the consumer can stall navigation until the test timeout. Limit the URL pattern, resolve credentials before navigation, keep the callback small, and remove the intercept in a finally block. Observation-only listeners do not impose this same blocking behavior.

Distinguish a policy failure from its near-misses

The fastest way to misfile a network security defect is to start from the WebDriver exception. A page-load timeout can mean DNS failure, TLS rejection, an authentication request waiting for a response, a deliberately blocked intercept, or a document whose body never finished. The event sequence shows the last browser-network boundary that completed.

Use a compact event reporter that emits allowlisted fields. For a normal redirect, useful output looks like this:

Example
beforeRequestSent request=12 redirect=0 GET https://app.example.test/continue
responseStarted   request=12 redirect=0 status=302 location=<redacted-origin-only>
beforeRequestSent request=12 redirect=1 GET https://id.example.test/login
responseCompleted request=12 redirect=1 status=200 fromCache=false

The exact request id is browser-generated, so compare relationships rather than hard-coding its value. The sequence supports four statements: the application returned an HTTP redirect, the browser followed it, the target belonged to a different origin, and the final document completed. A screenshot can support none of those statements.

The following patterns separate common lookalikes:

Observable patternLikely boundaryEvidence that decides it
responseStarted is 200 but CSP is absentApplication, gateway, or cached response policyCompare isFromCache, the exact response URL, and the same environment's response headers outside the browser
beforeRequestSent is followed by onFetchError and no responseStartedDNS, proxy, connection, or TLS pathPreserve getErrorText, browser version, requested URL, and whether insecure certificates were accepted
responseStarted is 302 but the next request has a new id and redirectCount zeroSeparate navigation rather than the same HTTP redirect chainInspect the initiator, navigation id, and triggering browser action
responseStarted is 401 but onAuthRequired never appearsApplication-level unauthorized response or a challenge without browser handlingCheck WWW-Authenticate, response body contract, and whether the route uses HTTP authentication
onAuthRequired repeats for one requestRejected credentials or the wrong challenge scopeCompare scheme and realm, count attempts, then cancel rather than retry forever
the origin returns the header but the browser event does notCache, service worker, intermediary, or a different URLCheck isFromCache, final URL, redirect hops, and a fresh-session run

A direct command is useful as a countercheck, provided the endpoint is safe to query from the engineer's machine:

Shell
curl -sS -D - -o /dev/null 'https://staging.example.test/account'

That command prints the response headers from curl's path. It does not replace the BiDi assertion. If curl sees CSP and the browser does not, the disagreement is the evidence: the clients may use different proxies, caches, protocols, cookies, or routes. If both miss it, the response policy is a stronger first hypothesis. Avoid adding authentication headers to a shared shell history just to make this comparison.

For a content-free diagnostic result, pass the exact final document URL to this script. It checks status and required header presence without writing header values to job output:

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

url=${1:?Usage: check-security-headers.sh <final-document-url>}
header_file=$(mktemp)
trap 'rm -f "$header_file"' EXIT

status=$(
  curl --silent --show-error \
    --dump-header "$header_file" \
    --output /dev/null \
    --write-out '%{http_code}' \
    "$url"
)

if [[ "$status" != 200 ]]; then
  printf 'unexpected HTTP status: %s\n' "$status" >&2
  exit 1
fi

if ! grep -iq '^content-security-policy:' "$header_file"; then
  printf 'Content-Security-Policy is missing\n' >&2
  exit 2
fi

if [[ "$url" == https://* ]] &&
   ! grep -iq '^strict-transport-security:' "$header_file"; then
  printf 'Strict-Transport-Security is missing on HTTPS\n' >&2
  exit 3
fi

printf 'status and required security header names passed\n'

Certificate failures deserve restraint. onFetchError can show that no HTTP response started and can preserve the browser's error text. It does not provide a complete certificate-chain audit. If the test sets acceptInsecureCerts, it has deliberately changed the boundary under investigation. Such a run cannot prove that a normal user would accept the certificate. Keep certificate validation in infrastructure or TLS checks, and use the browser event only to correlate the failed navigation.

HSTS can also create a misleading redirect story. A remembered browser policy may upgrade http to https without a 301 or 302 from the origin. If the requirement is "the HTTP endpoint redirects," use a clean profile and inspect the response. If the requirement is "the browser never sends cleartext traffic for this host," an HSTS-aware browser path is relevant, but it needs a separately named test. Mixing those claims produces a test that changes meaning depending on profile history.

Response completion is not always required. A missing CSP header is known as soon as responseStarted arrives. Waiting for responseCompleted adds the body download to the timeout and can turn a crisp policy failure into a page-load failure. Use completion when bytes, cache behavior, or full delivery is part of the claim. Choose the earliest event that contains the evidence you need.

Roll the checks into CI without leaking secrets

Start with a small route inventory, not a listener attached to the entire regression suite. Select representative documents for anonymous pages, authenticated pages, administrative pages, file responses, and cross-origin handoffs. For each route, write the security contract in product terms. Examples include "admin documents deny framing," "the reset token never leaves the account origin," and "the protected endpoint challenges with the qa-admin realm." Those statements survive API refactors better than a generic collection of header names.

Build one event collector that owns subscription and cleanup for a single WebDriver session. It should filter before storing, cap the number of events, and expose typed queries such as document response by URL or redirect hops by request id. It should not offer a convenience method that serializes every network payload. A page with many resources can produce hundreds of events, and retaining all header values increases memory use and incident exposure without improving most assertions.

Introduce the collector in observation mode first. Run it on the chosen routes and compare passing evidence across the browser and Grid versions that the team actually supports. This phase catches URL normalization, cache behavior, proxy-added headers, and browser support gaps before a security rule starts blocking merges. Observation mode still needs redaction. "Non-blocking" does not mean "safe to log secrets."

Once the event stream is stable, promote one requirement at a time. A practical order is:

  1. Fail when the expected document response event is missing. This makes broken BiDi plumbing visible.
  2. Enforce header names and directive values on routes with fixed policies.
  3. Add redirect-origin rules with all URL values redacted before reporting.
  4. Add authentication challenge checks only on dedicated endpoints with synthetic or tightly scoped accounts.
  5. Run a negative fixture that proves each detector fails for the intended reason.

That negative fixture is important. A header test that remains green after the fixture removes CSP is not a security check. A redirect detector that prints a token before failing is unsafe despite catching the bug. An auth test that succeeds when the server omits WWW-Authenticate proves only that some other login state was present.

These GitHub Actions steps run the three focused fixtures, scan their final JUnit reports for the synthetic values used above, and upload reports only after the scan passes:

YAML
- name: Run BiDi network security fixtures
  id: security_tests
  continue-on-error: true
  run: >-
    mvn -B
    -Dtest=SecurityHeaderTest,RedirectBoundaryTest,BasicAuthenticationTest
    test

- name: Scan JUnit evidence for synthetic secrets
  id: evidence_scan
  if: ${{ always() }}
  run: |
    test -d target/surefire-reports
    for canary in 'abc123' 'correct-horse'; do
      if grep -R -Fq -- "$canary" target/surefire-reports; then
        printf 'unsafe JUnit evidence, value withheld\n' >&2
        exit 1
      fi
    done

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

- name: Restore the fixture failure
  if: ${{ always() && steps.security_tests.outcome == 'failure' }}
  run: exit 1

Keep failed-attempt artifacts separate from retry results. A retry may hit a warm cache, reuse authentication state, or skip the original redirect. If the framework overwrites the first event record with a passing attempt, it destroys the only evidence that can explain the intermittent weakness. Use a fresh browser session for a retry that is meant to validate reproducibility, and label both attempts with their own session identity.

Version drift is a concrete maintenance cost. Selenium's Java BiDi Network API is Beta, browsers implement protocol features on their own schedules, and Grid must forward the WebSocket connection correctly. Pin the binding and browser versions in the CI image or record their effective versions with every result. Upgrade in a branch that runs the negative fixtures. A compilation error during an upgrade is cheaper than a listener that silently stops matching the intended event.

Latency depends on the chosen phase. Observation-only header checks add event processing and assertion time but do not pause requests. Authentication interception pauses a request and therefore sits directly on the navigation's critical path. Response-completed checks wait for bodies that a response-started check can ignore. Running every rule on every resource magnifies all three costs. Route filtering and a focused security smoke group keep the signal strong.

Artifact policy should be reviewed like application logging. Safe evidence commonly includes:

  • A generated test and session identifier.
  • Browser and Selenium versions.
  • HTTP method and a URL with user info, query values, and sensitive path segments removed.
  • Request id, redirect count, browsing context, and navigation id.
  • Status, protocol, response phase, and isFromCache.
  • Presence or normalized values of explicitly approved security headers.
  • The name of a rejected sensitive query key, never its value.
  • Challenge scheme and expected realm, never credentials or Authorization.

Do not treat hashing as automatic redaction. Reset tokens and short codes may have enough structure or low enough entropy to be attacked offline. If a value is not needed to correlate events within the test process, do not retain it. When correlation is necessary, assign an in-memory sequence number to the event rather than exporting a token-derived identifier.

Parallel execution adds an ownership problem. A static listener or shared event list can mix traffic from two sessions and make one test assert another test's redirect. Keep Network, futures, and event collections scoped to the driver instance. Close the Network object before quitting the driver, and remove active intercepts in finally blocks. A test that times out while a request remains blocked can otherwise affect teardown and obscure the first failure.

The rollout is complete only when someone owns policy changes. CSP directives, identity-provider origins, and authentication realms can change for valid reasons. Require the security or platform owner to review that contract change, then update the assertion and its negative fixture together. Blindly regenerating an expected header from the current response turns the test into a recorder.

Know when BiDi is the wrong layer

Use an API or component test when the browser adds no relevant behavior. Checking the same fixed headers on hundreds of JSON endpoints through Firefox is slower and harder to diagnose than asserting them at the gateway or service. Keep a few browser checks to prove the end-to-end delivery path, then cover the broad endpoint matrix closer to the server.

Do not present these tests as a vulnerability scanner. They verify known contracts. They do not discover injection flaws, broken access control, unsafe deserialization, request smuggling, or an exploitable CSP bypass. A penetration test and security review exercise adversarial paths that a small set of fixed checks cannot enumerate.

Certificate-chain validity, expiry monitoring, mutual TLS, HSTS preload status, and cipher policy belong in TLS-aware tooling and infrastructure monitoring. A browser fetch error can connect a user journey to a transport failure, but it cannot replace those controls. Turning on acceptInsecureCerts to make the UI flow pass removes the very behavior a certificate test should inspect.

Header presence is also weaker than browser enforcement. A CSP string may exist yet allow unsafe-inline, an overly broad source, or a bypass in the page. Assert the required directives, then add a focused browser behavior test where the threat warrants it. For example, a framing control can be checked from an allowed and disallowed parent origin. The network assertion establishes policy delivery; the behavior test establishes the browser outcome.

BiDi authentication handling is for HTTP authentication challenges. Do not use continueWithAuth for an HTML sign-in form, OAuth consent page, one-time password, CAPTCHA, or two-factor prompt. Those are application and identity journeys. Automating them through a protocol shortcut can hide the screens, cookies, redirects, and user decisions that need coverage.

Avoid active interception on production traffic. Even a test account can pause or modify a request in a shared system, and a broad URL pattern may catch third-party resources. Observation against production may be acceptable under an approved monitoring policy if the collector is aggressively redacted, but mutation needs a dedicated environment and explicit authorization.

Service workers and browser caches sometimes deserve their own test design. A header check against the network origin cannot explain a document served entirely by a service worker unless the test captures the relevant browser path and cache state. Decide whether the requirement concerns the origin response, the first visit, or a returning user's offline path. Give each path its own setup and evidence.

Finally, skip BiDi when the supported browser or remote provider cannot deliver the required event reliably. A test that passes only because its listener never fires is dangerous, while a test that always fails on unsupported infrastructure is noise. Gate the instrumentation with a known local negative fixture, record support as a suite capability, and keep a server-side control until the browser path is trustworthy.

// 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 w3.org reference

    w3.org

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

  4. 04
    Official developer.mozilla.org reference

    developer.mozilla.org

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

FAQ / QUICK ANSWERS

Questions testers ask

Can Selenium check security response headers without a proxy?

Yes. WebDriver BiDi response events expose the status, URL, protocol, cache state, and response headers seen by the browser. Keep a normal UI assertion as well, because a header record alone does not prove the feature still works.

Why does a security header test pass locally and fail on Grid?

Different browser versions, cached responses, reverse proxies, and missing BiDi WebSocket support can change the evidence. Compare the effective browser version, final URL, isFromCache value, and event sequence before blaming the application.

Do BiDi network listeners expose Authorization headers?

Request events can contain sensitive header and cookie values, so treat raw event payloads as confidential. Record an allowlisted set of fields and redact URL queries before attaching evidence to CI.

How do I test a cross-origin redirect safely?

Compare the scheme, host, and port of each redirect hop, then reject sensitive query keys before logging the URL. Use synthetic credentials and local or dedicated test endpoints, never a real session token.

What is the difference between authRequired and a 401 response?

An authRequired event means the browser is about to ask for credentials after receiving an authentication challenge. An application can return a plain 401 JSON response without creating that browser challenge, so status alone is not enough.