PRACTICAL GUIDE / Selenium BiDi HTTP authentication negative testing
Prove HTTP authentication failures with Selenium BiDi
Build reliable Selenium BiDi checks for canceled prompts and rejected credentials, then separate real HTTP challenges from ordinary 401 responses.
In this guide6 sections
What you will learn
- Understand what the browser is waiting for
- Build a denial fixture that can expose a bad test
- Exercise cancellation and rejected credentials separately
- Tell an authentication failure from a similar 401
Your protected page stays open in CI after the test cancels the browser's credential prompt. Another run hangs until the page-load timeout, and the only artifact is a screenshot of an empty tab. Both failures look like authentication trouble, but they point to different mistakes in the test harness.
A useful negative check has to prove that the browser received an HTTP challenge, that the handler made the intended denial decision, and that protected state stayed unreachable. WebDriver BiDi exposes those boundaries. It also gives you enough control to deadlock navigation or send a password to the wrong request if the intercept is too broad.
Understand what the browser is waiting for
Browser-managed HTTP authentication starts at the response, not at a login form. A server answers an unauthenticated request with status 401 and a WWW-Authenticate header. That header names a scheme such as Basic or Digest and can include a realm. The browser can then ask for credentials and repeat the request with an Authorization header. MDN's HTTP authentication guide documents that challenge and response sequence, including the different 407 flow used by an authenticating proxy.
This distinction matters because many applications also use 401 as an API status. A single-page application might fetch /api/profile, receive a JSON body such as {"code":"session_expired"}, clear its cookie, and render a sign-in link. Unless the response starts HTTP authentication with a suitable WWW-Authenticate challenge, the browser has no credential prompt for BiDi to control. The status code alone does not make it an authRequired case.
The BiDi network event is narrower. The WebDriver BiDi specification defines network.authRequired as the event emitted when the user agent is going to prompt for authorization credentials. Selenium's Java ResponseDetails exposes the request, the response data, and the blocked flag for that event.
What does not arrive is the challenge object. The specification names the response field authChallenges, an array. Selenium 4.43.0 parses a singular authChallenge key in ResponseData, so getAuthChallenge() returns an empty Optional even against a spec-conformant remote end. Firefox 153 sends the array and Selenium still reports empty. Chrome 151 sends no challenge field at all, and its authRequired payload carries an empty header list, so there is no WWW-Authenticate value to fall back on inside that event. The AuthChallenge class and its getScheme and getRealm accessors are real, but nothing populates them today. Read the scheme and realm from the WWW-Authenticate header on the completed response instead.
Observation and control are separate operations. Registering onAuthRequired subscribes a callback to the event. Adding a network intercept for InterceptPhase.AUTH_REQUIRED makes matching events blocked, and the protocol then stores that request as blocked and waits for a continuation command. Without the intercept there is no blocked request for a continuation command to resolve, and on headless Chrome the event does not arrive at all. The browser answers its own prompt, emits network.fetchError with net::ERR_INVALID_AUTH_CREDENTIALS, and completes the 401 response.
Chrome's continuation path is the part to plan around. On Chrome 151 with ChromeDriver 151.0.7922.138 and Selenium 4.43.0, an AUTH_REQUIRED intercept does block the request and does deliver the event with isBlocked true. Every continuation command then fails. cancelAuth, continueWithAuth, and continueWithAuthNoCredentials all return {"error":"unknown error","message":"Invalid InterceptionId."}, and none of them returns until the navigation has already aborted. This is not a handler-thread deadlock, because dispatching the same command from a separate thread produces the same error. The request ends as net::ERR_ABORTED and driver.get throws org.openqa.selenium.TimeoutException: timeout: Timed out receiving message from renderer.
Adding an intercept therefore creates an obligation you can only discharge on a browser that accepts the command. Every blocked event must receive one valid terminal decision. A callback that throws before making that decision, or a branch that returns without issuing any command, leaves driver.get waiting until its page-load timeout. Keep that timeout short in authentication tests so a stuck handler reports in seconds rather than minutes.
Selenium's Java Network module gives the handler three authentication choices. continueWithAuth(requestId, new UsernameAndPassword(...)) provides password credentials. cancelAuth(requestId) chooses the protocol's cancel action. continueWithAuthNoCredentials(requestId) chooses the protocol's default action. Despite its Java name, the last method is not the same as a deterministic rejection. It returns control to the browser's normal authentication handling, whose visible result can differ by browser and version. Firefox honours all three commands, which is why the worked denial examples below run there.
Use cancelAuth when the test means "the user refuses to authenticate" or "policy forbids credentials for this challenge." Use continueWithAuth once when the scenario means "the user submitted known-bad credentials." Reserve continueWithAuthNoCredentials for a test that deliberately covers the browser's default behavior. Mixing the three makes failures hard to classify because a native prompt, a canceled response, and a rejected credential attempt have different network histories.
Do not substitute failRequest at this phase. The BiDi specification rejects network.failRequest for a request blocked at authRequired; that command belongs to a different interception boundary. A simulated transport failure also tests a different product condition. The application cannot respond to a rejected identity if the test turns the request into a network error first.
BiDi must be enabled when the WebDriver session is created. Current Selenium Java options expose enableBiDi(), which requests the WebSocket connection used for events and commands. Enabling it later is too late because session capabilities are negotiated during session creation. On Grid, the selected node and browser must support the same BiDi feature set as the binding. A successful classic WebDriver session does not prove that the network module is available.
Treat the network API as a versioned integration point. The Selenium Java classes are marked beta, and the W3C document is a working draft. Pin Selenium, browser, and driver inputs for the job, then run a small contract test before blaming the application. That contract test should exercise a challenge you own, not a public demo endpoint whose credentials or response body can change without notice.
Build a denial fixture that can expose a bad test
A local challenge server removes three sources of noise: shared accounts, identity-provider policy, and an external network hop. It also lets the test observe whether any Authorization header reached the server without recording the header value. The fixture is not proof that production authentication is correct. Its job is to prove that your BiDi handler performs the denial action you think it performs.
Start by checking the server contract outside Selenium. The following diagnostic accepts a URL, saves headers in a temporary file, and verifies the two facts that matter: the response is 401, and it advertises the expected Basic realm. It sends no username or password.
#!/usr/bin/env bash
set -euo pipefail
url=${1:?Usage: check-auth-challenge.sh <protected-url>}
header_file=$(mktemp)
trap 'rm -f "$header_file"' EXIT
status=$(
curl --silent --show-error \
--output /dev/null \
--dump-header "$header_file" \
--write-out '%{http_code}' \
"$url"
)
test "$status" = "401"
tr -d '\r' < "$header_file" \
| grep --fixed-strings --ignore-case \
'WWW-Authenticate: Basic realm="qa-denial"'If this check returns 200, the test data is already authenticated or the route is unprotected. A 302 points to an application redirect, not an HTTP authentication prompt. A 401 without the challenge header is an application-level unauthorized response. Fixing a BiDi callback cannot repair any of those server contracts.
The Java class below is the fixture only. It creates an in-process Basic authentication server on loopback, exposes the protected URL and a matching UrlPattern, and counts two things: how many requests reached the route, and how many of those carried an Authorization header. It counts that header without ever storing or printing its value. Both browser contract classes in this article extend it, so the start method is idempotent and cleanup runs from a shutdown hook rather than an @AfterAll that would fire once per subclass.
package example;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.openqa.selenium.bidi.network.UrlPattern;
/** In-process Basic authentication fixture shared by the browser contract classes. */
abstract class BasicAuthFixture {
protected static final String ACCEPTED_AUTHORIZATION =
"Basic "
+ Base64.getEncoder()
.encodeToString("qa-user:correct-horse".getBytes(StandardCharsets.UTF_8));
protected static final AtomicInteger TOTAL_REQUESTS = new AtomicInteger();
protected static final AtomicInteger REQUESTS_WITH_AUTH = new AtomicInteger();
private static HttpServer server;
protected static String protectedUrl;
@BeforeAll
static synchronized void startFixture() throws IOException {
if (server != null) {
return;
}
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/protected", BasicAuthFixture::respond);
server.start();
protectedUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/protected";
Runtime.getRuntime().addShutdownHook(new Thread(() -> server.stop(0)));
}
@BeforeEach
void resetCounters() {
TOTAL_REQUESTS.set(0);
REQUESTS_WITH_AUTH.set(0);
}
protected static UrlPattern protectedRoute() {
return new UrlPattern()
.protocol("http")
.hostname("127.0.0.1")
.port(Integer.toString(server.getAddress().getPort()))
.pathname("/protected");
}
private static void respond(HttpExchange exchange) throws IOException {
TOTAL_REQUESTS.incrementAndGet();
String authorization = exchange.getRequestHeaders().getFirst("Authorization");
if (authorization != null) {
REQUESTS_WITH_AUTH.incrementAndGet();
}
boolean accepted = ACCEPTED_AUTHORIZATION.equals(authorization);
if (!accepted) {
exchange.getResponseHeaders().set("WWW-Authenticate", "Basic realm=\"qa-denial\"");
}
int status = accepted ? 200 : 401;
String html =
accepted
? "<h1 data-testid='protected'>Protected content</h1>"
: "<h1 data-testid='denied'>Not authorized</h1>";
byte[] body = html.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
exchange.sendResponseHeaders(status, body.length);
try (var output = exchange.getResponseBody()) {
output.write(body);
}
}
}The local fixture uses plain HTTP only because its credentials are synthetic and the server is bound to loopback. Do not copy that transport choice into a staging environment. Basic credentials are encoded, not encrypted, so a real exchange needs TLS. Each contract class also creates a new browser for every method. That costs startup time, but it prevents a successful Basic authentication from an earlier test being reused from the browser's authentication cache.
The Chrome contract asserts only what Chrome actually delivers, which is why it issues no continuation command at all. The first method installs the AUTH_REQUIRED intercept, lets the navigation abort, and then proves four things from the event: the request was blocked, the blocked request is the one the intercept claims, the aborted fetch belongs to the same URL, and no credential ever reached the server. The second method removes the intercept and takes the 401 from the flow that actually completes.
package example;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
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.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.openqa.selenium.TimeoutException;
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.InterceptPhase;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
@Execution(ExecutionMode.SAME_THREAD)
class ChromeAuthChallengeContractTest extends BasicAuthFixture {
private WebDriver driver;
@BeforeEach
void openFreshBrowser() {
ChromeOptions options = new ChromeOptions().enableBiDi();
options.addArguments("--headless=new");
driver = new ChromeDriver(options);
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(3));
}
@AfterEach
void closeBrowser() {
if (driver != null) {
driver.quit();
}
}
@Test
void chromeBlocksTheChallengeAndNamesTheChallengedRequest() throws Exception {
CompletableFuture<ResponseDetails> blockedChallenge = new CompletableFuture<>();
CompletableFuture<String> abortedRequest = new CompletableFuture<>();
try (Network network = new Network(driver)) {
String interceptId =
network.addIntercept(
new AddInterceptParameters(InterceptPhase.AUTH_REQUIRED)
.urlPattern(protectedRoute()));
try {
network.onAuthRequired(blockedChallenge::complete);
network.onFetchError(
event -> {
if (protectedUrl.equals(event.getRequest().getUrl())) {
abortedRequest.complete(event.getErrorText());
}
});
assertThrows(TimeoutException.class, () -> driver.get(protectedUrl));
ResponseDetails challenge = blockedChallenge.get(5, TimeUnit.SECONDS);
String errorText = abortedRequest.get(5, TimeUnit.SECONDS);
assertAll(
() -> assertTrue(challenge.isBlocked()),
() -> assertEquals(protectedUrl, challenge.getRequest().getUrl()),
() -> assertEquals("GET", challenge.getRequest().getMethod()),
() -> assertEquals(protectedUrl, challenge.getResponseData().getUrl()),
() -> assertEquals(List.of(interceptId), challenge.getIntercepts()),
() -> assertTrue(errorText.contains("net::ERR_ABORTED"), errorText),
() -> assertEquals(1, TOTAL_REQUESTS.get()),
() -> assertEquals(0, REQUESTS_WITH_AUTH.get()),
// Compatibility pins. These fail the day Chrome or Selenium closes the gap.
() -> assertEquals(-1, challenge.getResponseData().getStatus()),
() -> assertTrue(challenge.getResponseData().getHeaders().isEmpty()),
() -> assertTrue(challenge.getResponseData().getAuthChallenge().isEmpty()));
} finally {
network.removeIntercept(interceptId);
}
}
}
@Test
void theCompletedResponseCarriesThe401AndTheChallengeHeader() throws Exception {
CompletableFuture<ResponseDetails> completed = new CompletableFuture<>();
try (Network network = new Network(driver)) {
network.onResponseCompleted(
event -> {
if (protectedUrl.equals(event.getResponseData().getUrl())) {
completed.complete(event);
}
});
driver.get(protectedUrl);
ResponseDetails response = completed.get(10, TimeUnit.SECONDS);
String challengeHeader =
response.getResponseData().getHeaders().stream()
.filter(header -> "www-authenticate".equals(header.getName().toLowerCase(Locale.ROOT)))
.map(header -> header.getValue().getValue())
.findFirst()
.orElse("");
assertAll(
() -> assertEquals(401, response.getResponseData().getStatus()),
() -> assertEquals("Basic realm=\"qa-denial\"", challengeHeader),
() -> assertFalse(response.isBlocked()),
() -> assertFalse(driver.getPageSource().contains("Protected content")),
() -> assertEquals(1, TOTAL_REQUESTS.get()),
() -> assertEquals(0, REQUESTS_WITH_AUTH.get()));
}
}
}Two details make the first method honest. The page-load timeout is three seconds, so the abort is deliberate and fast rather than a hidden ten-second stall, and assertThrows states that the navigation is expected to fail instead of swallowing the exception in a comment. getIntercepts() is compared to the identifier returned by addIntercept, which fails if a stray intercept from another test claims the request first.
The last three assertions are compatibility pins, not aspirations. Chrome reports status -1, an empty header list, and no challenge object on authRequired, so the test states those facts. Each one fails the day Chrome starts populating the field or Selenium fixes the singular authChallenge parse, and a failing pin is exactly the signal that should reopen the scenarios below on Chrome.
The completed-response method is where the 401 lives. Without an AUTH_REQUIRED intercept, headless Chrome resolves its own prompt and network.responseCompleted carries the real status and the real headers, including Www-authenticate: Basic realm="qa-denial". That single event proves the server issued a genuine browser-managed challenge, which is the fact the scheme and realm assertions were reaching for before the challenge object turned out to be empty.
@Execution(ExecutionMode.SAME_THREAD) keeps each class sequential even when the surrounding JUnit suite enables parallel execution. The in-process server and its counters are shared, so concurrent resets would corrupt the oracle. The cost is limited concurrency for these small contract classes. A larger fixture should use per-test server state instead of serializing unrelated authentication cases.
The URL pattern is deliberately specific. An unscoped AUTH_REQUIRED intercept can pause challenges from favicons, embedded resources, redirects, or another tab. That is annoying for cancellation tests and dangerous for positive tests that provide a secret. Match protocol, host, port, and path where possible, then validate the actual event URL again before choosing a continuation.
Removing the intercept belongs in finally, not after the assertions. JUnit stops executing the normal path as soon as an assertion or wait fails. If the intercept survives in a shared session, a later test can block on a handler it did not install. Closing Network clears the authentication listener installed through that module, while removeIntercept makes the request-control lifecycle explicit.
Exercise cancellation and rejected credentials separately
Cancellation proves that no credential-bearing retry left the browser. It does not prove that the server rejects a bad password, because the test never supplied one. A rejected-password scenario needs a different server observation: exactly one request arrived with an Authorization header, that request did not receive protected content, and the flow terminated instead of prompting forever.
Both of those scenarios need a browser that accepts a continuation command, so they run on Firefox. On Firefox 153 with geckodriver, authRequired arrives with isBlocked true and the real 401 status, cancelAuth and continueWithAuth both succeed, network.responseCompleted reports the 401, and driver.get returns normally with the denial document rendered. The class below extends the same fixture and holds both denial cases.
package example;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
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.InterceptPhase;
import org.openqa.selenium.bidi.network.ResponseDetails;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.firefox.FirefoxOptions;
@Execution(ExecutionMode.SAME_THREAD)
class FirefoxAuthDenialTest extends BasicAuthFixture {
private WebDriver driver;
@BeforeEach
void openFreshBrowser() {
FirefoxOptions options = new FirefoxOptions().enableBiDi();
options.addArguments("-headless");
driver = new FirefoxDriver(options);
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(15));
}
@AfterEach
void closeBrowser() {
if (driver != null) {
driver.quit();
}
}
@Test
void cancellationDoesNotSendCredentials() throws Exception {
CompletableFuture<ResponseDetails> observedChallenge = new CompletableFuture<>();
CompletableFuture<ResponseDetails> completedResponse = new CompletableFuture<>();
AtomicReference<String> callbackProblem = new AtomicReference<>();
try (Network network = new Network(driver)) {
String interceptId =
network.addIntercept(
new AddInterceptParameters(InterceptPhase.AUTH_REQUIRED)
.urlPattern(protectedRoute()));
try {
network.onAuthRequired(
event -> {
if (!protectedUrl.equals(event.getResponseData().getUrl())) {
callbackProblem.set("Unexpected challenged URL");
}
observedChallenge.complete(event);
network.cancelAuth(event.getRequest().getRequestId());
});
network.onResponseCompleted(
event -> {
if (protectedUrl.equals(event.getResponseData().getUrl())) {
completedResponse.complete(event);
}
});
driver.get(protectedUrl);
ResponseDetails challenge = observedChallenge.get(10, TimeUnit.SECONDS);
ResponseDetails finalResponse = completedResponse.get(10, TimeUnit.SECONDS);
assertAll(
() -> assertNull(callbackProblem.get()),
() -> assertTrue(challenge.isBlocked()),
() -> assertEquals(401, challenge.getResponseData().getStatus()),
() -> assertEquals(401, finalResponse.getResponseData().getStatus()),
() -> assertFalse(finalResponse.isBlocked()),
() -> assertTrue(driver.getPageSource().contains("Not authorized")),
() -> assertEquals(1, TOTAL_REQUESTS.get()),
() -> assertEquals(0, REQUESTS_WITH_AUTH.get()));
} finally {
network.removeIntercept(interceptId);
}
}
}
@Test
void wrongPasswordIsSentOnceThenTheSecondChallengeIsCancelled() throws Exception {
AtomicInteger challengeCount = new AtomicInteger();
CompletableFuture<ResponseDetails> rejectedAttempt = new CompletableFuture<>();
CompletableFuture<ResponseDetails> completedResponse = new CompletableFuture<>();
try (Network network = new Network(driver)) {
String interceptId =
network.addIntercept(
new AddInterceptParameters(InterceptPhase.AUTH_REQUIRED)
.urlPattern(protectedRoute()));
try {
network.onAuthRequired(
event -> {
int number = challengeCount.incrementAndGet();
String requestId = event.getRequest().getRequestId();
if (number == 1) {
network.continueWithAuth(
requestId, new UsernameAndPassword("qa-user", "wrong-on-purpose"));
} else {
rejectedAttempt.complete(event);
network.cancelAuth(requestId);
}
});
network.onResponseCompleted(
event -> {
if (protectedUrl.equals(event.getResponseData().getUrl())
&& challengeCount.get() >= 2) {
completedResponse.complete(event);
}
});
driver.get(protectedUrl);
ResponseDetails finalChallenge = rejectedAttempt.get(10, TimeUnit.SECONDS);
ResponseDetails finalResponse = completedResponse.get(10, TimeUnit.SECONDS);
assertAll(
() -> assertTrue(finalChallenge.isBlocked()),
() -> assertEquals(401, finalChallenge.getResponseData().getStatus()),
() -> assertEquals(401, finalResponse.getResponseData().getStatus()),
() -> assertEquals(2, challengeCount.get()),
() -> assertTrue(driver.getPageSource().contains("Not authorized")),
() -> assertEquals(2, TOTAL_REQUESTS.get()),
() -> assertEquals(1, REQUESTS_WITH_AUTH.get()));
} finally {
network.removeIntercept(interceptId);
}
}
}
}The cancellation method requires four independent facts before it passes: the callback saw the URL it expected, the challenge was blocked, the response completed with 401, and the server counted exactly one request with zero Authorization headers. A DNS failure, a broadened intercept, or a handler that silently declined to cancel cannot satisfy that combination.
The rejected-credentials method sends one synthetic wrong password on the first challenge and cancels the second. It asserts two challenge events, two requests at the server, and exactly one of them carrying an Authorization header. That last pair is the real oracle. If continueWithAuth stopped delivering credentials, the count would drop to zero; if the handler looped, it would climb past one.
This method makes the repeated challenge a compatibility contract for the pinned browser in your job. The HTTP server's behavior is controlled: invalid credentials produce 401 and another WWW-Authenticate header. The BiDi event should therefore give the handler a chance to make the second decision. If a browser version ends the exchange differently, the test should fail its contract lane and produce a browser-compatibility investigation, not silently loosen the product assertion.
Do not port these two methods to Chrome and add a try block around the continuation to make them pass. On Chrome 151 the command fails, the navigation aborts, and any oracle you build on top of that is measuring the interception defect rather than the product. Run the denial lane on Firefox, keep the Chrome observation contract beside it, and revisit the split when a Chrome pin starts failing.
Never write the handler as "keep supplying the wrong password until something changes." Real identity systems count failures. Even a dedicated staging user can be locked, throttled, challenged with CAPTCHA, or reported to a security system. One failed submission followed by cancellation is enough to prove the browser sent the controlled input and did not reach the protected representation. If the product requirement concerns lockout thresholds, test that policy through an approved service-level fixture where account state can be reset safely.
A third negative case is a challenge from the wrong protection space. Imagine a reports page that redirects to files.example.test, while the helper was written to authenticate any host ending in example.test. Both hosts can issue Basic challenges, but they do not necessarily trust the same operator or use the same realm. A broad handler may turn an expected denial into a successful request, or worse, disclose a secret to a service that was never approved to receive it.
For a positive authentication helper, compare the event URL, scheme, and realm before providing credentials. On any mismatch, record an allowlisted reason and call cancelAuth for that request. Make the test fail after navigation returns. Do not throw first and hope cleanup resolves the blocked request. The terminal network decision must happen even when the policy check fails.
Realm comparison is useful evidence, but it is not a complete authorization policy by itself. Realms are server-provided strings, and two origins can choose the same value. The origin and route decide where a credential is allowed to go. Scheme and realm then confirm that the challenge is the one the test expected at that location.
There is a concrete cost to this defensive scope. Every URL pattern and policy check creates maintenance when hosts, ports, or paths change. A central helper reduces repetition, but it can also hide which tests are allowed to send which credentials. Keep the allowed target beside the test's credential fixture, and require a review when that scope expands. Convenience is not worth turning a network callback into a secret broadcaster.
Cancellation also has a coverage limit. It proves the unauthenticated representation and the browser's denial path. It says nothing about successful credential handling, logout, credential cache behavior, or authorization after authentication. Keep one small positive contract for those paths instead of making a negative test carry incompatible responsibilities.
Tell an authentication failure from a similar 401
Start diagnosis at the last network boundary you actually observed. If authRequired arrived with isBlocked true, the browser received a challenge covered by your intercept. If the callback recorded a cancel decision and driver.get still timed out, look at handler completion and browser behavior before blaming the application. If no event arrived, inspect the response contract and session setup before changing the callback.
The challenged response provides strong, allowlist-friendly evidence. Retain the URL after removing sensitive query values, isBlocked, the handler decision, the status of the completed response, and the browser, driver, and Selenium versions. Take the scheme and realm from the WWW-Authenticate header of that completed response, because the event's own challenge object is empty on both browsers. Keep the request ID only as a correlation value inside that run. Browser-generated IDs are not stable assertions across sessions.
Do not retain the raw Authorization header. In Basic authentication, its value can be decoded back into the username and password. Redacting only the password string is not enough if the encoded header remains in an event dump. The same caution applies to URLs that contain user information. Chrome supports HTTP and HTTPS URLs containing userinfo and can use those credentials during navigation, while suppressing them in the address bar afterward. That display treatment does not prove that credentials were stripped before the request. Event serializers should select known-safe fields rather than serialize every getter and clean the output afterward.
Several evidence patterns separate common lookalikes.
An ordinary API denial has a 401 response but no browser authentication event. Check for WWW-Authenticate and inspect the response body through the application's normal test surface. If the app renders a "session expired" state from JSON, assert that state. Adding an AUTH_REQUIRED intercept will not manufacture a browser-managed challenge.
A forbidden response has status 403 because the server understood the request and refuses it. Many products use that response after authentication when the current user lacks permission, but the status alone does not prove an identity was established. Confirm the authentication state, roles, resource ownership, and authorization policy before deciding that a different password is relevant. A 403 belongs in an access-control test unless the product contract explicitly maps another condition to that status.
A transport failure produces no challenged response. DNS errors, connection refusal, proxy failure, and TLS rejection can surface through onFetchError or a WebDriver navigation exception. The absence of responseStarted and authRequired is part of the diagnosis. Do not report "invalid password" because the page-load exception happened near authentication code.
A default browser prompt or a navigation failure after the callback can mean the handler called continueWithAuthNoCredentials, not cancelAuth. The exact surface belongs to the browser's default authentication handling. It is also possible that another challenge fell outside the intercept's URL pattern. Compare the affected route with the event URLs instead of adding a blanket alert dismissal or exception catch.
A page-load timeout immediately after an authRequired event usually belongs to the automation. Look for a branch that returned without calling a continuation, an exception thrown while reading challenge data, a blocking secret lookup inside the callback, or two listeners trying to resolve the same request. Move slow setup before navigation. Keep the event handler to a bounded policy decision and one protocol command. Rule out the browser first when the run is on Chrome, because the same timeout appears there even when the handler is correct.
An error response from continueWithAuth or cancelAuth means the remote end will not resolve that request. Read the message rather than the class. Invalid InterceptionId. inside a BiDiException is what ChromeDriver 151 returns for a blocked authRequired request, and it appears even when the intercept is installed and isBlocked is true, so it is a browser limitation rather than a mistake in your handler. A no such request error from a browser that does honour the command points instead at a missing intercept, a command issued after another handler already resolved the request, or reuse of an ID from a different event. Preserve the protocol error and request correlation rather than replacing it with a generic timeout message.
The direct curl check from the previous section is a useful countercheck, not a replacement oracle. Curl and the browser may travel through different proxies, trust stores, caches, or service-worker paths. If curl sees the expected challenge and BiDi does not, that disagreement narrows the search to the browser route, BiDi session, listener timing, intercept match, or implementation support. If neither sees the challenge, the server or test data is the better first suspect.
Screenshots have a smaller role. They can prove that the denial page, native prompt, or browser error page was visible. They cannot prove which request received credentials, which realm challenged the browser, or whether a callback resolved the blocked request. Pair a screenshot with the network decision record and a product assertion, then let each artifact answer only the question it can support.
When the failure appears only on Grid, inspect the returned webSocketUrl capability and the actual node's browser details. A local session and a remote session can run different browser versions even when both were requested as "chrome." Also confirm that intermediate infrastructure permits the BiDi WebSocket connection. A working classic WebDriver command channel does not demonstrate that the event channel stayed connected.
Roll the checks into CI without losing evidence
Begin rollout with the local cancellation contract, not the production route. Run it on the exact Selenium and browser combination selected for CI. Once it passes consistently without retries, add one application-facing denial assertion that uses the same handler lifecycle. This order tells you whether a later failure belongs to protocol plumbing or the product environment.
Keep authentication tests in a fresh browser session. HTTP credentials can be cached for a protection space, and cookies from an application login can make the protected route skip its challenge. Session isolation adds browser startup latency, but it removes a class of order-dependent false passes. If the suite cannot afford one session per test, group only cases that share the same explicit authentication state and put the denial case first.
Do not let a retry erase the first attempt. An immediate rerun can start with a different browser profile, a warmed proxy, or a temporarily locked account. If the CI platform retries at all, upload each attempt's allowlisted event record separately and report the first failure as flaky until someone classifies it. A passing retry is not evidence that the original denial path worked.
The workflow below assumes a Maven wrapper and the Java classes shown earlier. It records the Java, Chrome, and Firefox versions, runs both authentication contract classes and nothing else, and uploads Surefire reports when the job fails. It does not inject a real credential because the fixture owns synthetic values.
name: HTTP authentication contract
on:
pull_request:
workflow_dispatch:
jobs:
bidi-auth-denial:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: maven
- name: Record runtime inputs
run: |
java -version
google-chrome --version
firefox --version
- name: Run the isolated BiDi authentication contracts
run: >-
./mvnw -B
-Dtest=ChromeAuthChallengeContractTest,FirefoxAuthDenialTest
test
- name: Preserve failure reports
if: failure()
uses: actions/upload-artifact@v6
with:
name: bidi-auth-denial-reports
path: target/surefire-reports/
if-no-files-found: errorTreat the runner image as another pinned input. ubuntu-24.04 is more explicit than a moving ubuntu-latest label, but the preinstalled browser can still update. Recording the effective version makes a browser change visible in the failed job. Teams that require stronger reproducibility should use their approved browser image and publish its immutable reference with the report.
Surefire output should contain assertion messages and safe diagnostic fields, not a serialized network event. Build a small record with a test ID, sanitized origin and path, blocked flag, decision, completed status, the challenge header taken from that completed response, and a final marker. If a field has no diagnostic purpose, leave it out. This approach is easier to review than a long redaction list and less likely to expose a future field added by Selenium.
Roll out the wrong-password case after cancellation is stable. Use one synthetic attempt, verify the second challenge is bounded, and run it in an isolated lane if the application uses a shared identity service. A server-side counter or audit record is useful only when it identifies the test account without exposing the credential. Do not require production security logs for every pull request if that access expands the test's privacy footprint.
Cross-browser coverage should be earned. Start with the browser that matters most to the product, then add a contract lane for each additional browser before adding the product test to that matrix. Authentication prompts and BiDi implementation maturity can differ. A separate contract failure tells the team "this browser integration changed" instead of filing the same ambiguous application defect from every shard.
The costs are real. Fresh sessions increase execution time. A local server adds fixture code. Network interception adds a callback that can stall navigation. Pinning browser inputs adds maintenance. In return, the test can distinguish an application denial from an automation deadlock and can prove that a credential was not sent. For a security-sensitive path, that is usually a better trade than a fast test that reports only whether driver.get returned.
Know when BiDi authentication is the wrong tool
Do not use this mechanism for an HTML sign-in form. A form submission, validation message, CSRF token, password manager interaction, and post-login redirect are application behavior. Supplying HTTP credentials through continueWithAuth bypasses all of it and can produce a green result while the real login journey is broken.
OAuth, OpenID Connect, and SAML flows also need their own assertions. They involve redirects, state, nonce, cookies, identity-provider pages, and often more than one origin. A 401 from a token API does not automatically become browser-managed HTTP authentication. Test the UI journey or an approved session fixture, then inspect the application's authorization outcome.
Passkeys, one-time passwords, CAPTCHA, client certificates, and operating-system credential dialogs are outside this password-credential command. They involve authenticators, platform UI, certificates, or anti-automation controls that the network callback does not model. Naming the feature "authentication" does not make every credential mechanism interchangeable.
Avoid a bad-password browser test against production. Account lockout and security monitoring are product features, not test noise. Even on staging, get agreement on the account, failure limit, reset path, and audit impact. A local fixture is the right place to verify callback mechanics. A dedicated disposable identity is the right place to validate integration policy.
Do not use https://username:password@example.test/ as a shortcut. Chrome supports this userinfo syntax and can use the supplied credentials, even though it suppresses them in the address bar after navigation. The string can also leak through source control, command history, reports, or screenshots. It gives you poor evidence about the challenge that selected those credentials. The BiDi event and a scoped continuation make the decision explicit.
Skip interception when observation is enough. If the question is whether a protected route advertises the correct scheme and realm, an event listener plus a controlled browser cleanup may provide the evidence without taking ownership of the request. Adding an intercept changes timing and creates a required continuation. Control is useful only when the scenario needs to choose the response to the prompt.
Do not broaden the intercept to compensate for missing events until the server contract is known. A pattern that matches every URL can turn a harmless fixture problem into a suite-wide hang. It can also allow a positive handler to send credentials to an unexpected origin. Check the 401 and challenge header, confirm BiDi was enabled, register before navigation, and then adjust the narrow route with evidence.
Finally, do not collapse cancellation, invalid credentials, and insufficient permission into one parameterized assertion that expects "not authorized." Those paths differ at the protocol and product layers. Cancellation should send no credential-bearing retry. An incorrect password should be attempted once and rejected. A recognized user without permission should reach an authorization decision, commonly 403. Separate tests cost a little more setup, but each failure tells the engineer what actually broke.
// 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.
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.
- 01Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 04Official developer.mozilla.org reference
developer.mozilla.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why does Selenium BiDi authRequired never fire for my 401 response?
First, inspect the response for a valid WWW-Authenticate challenge. A JSON API that returns 401 without starting browser-managed HTTP authentication can be correct while producing no authRequired event.
Should I use cancelAuth or continueWithAuthNoCredentials for a denial test?
Calling cancelAuth sends the protocol's cancel decision for a request blocked at the authRequired phase. continueWithAuthNoCredentials selects the default action, so the browser may continue with its normal credential-prompt behavior instead of giving the test a deterministic cancellation.
Can Selenium BiDi test an incorrect Basic Auth password?
Yes on Firefox, where continueWithAuth delivers one synthetic wrong password and cancelAuth bounds the second challenge. Chrome 151 with ChromeDriver 151 rejects every continuation command with an Invalid InterceptionId error, so keep that lane observation-only. Run either version against a disposable account or local fixture because repeated failures can trigger a real system's lockout policy.
What evidence should an HTTP authentication negative test retain?
Keep the challenged URL, the blocked flag, the handler decision, the status of the completed response, the browser and driver versions, and the final product assertion. Selenium does not populate the challenge object today, so read the scheme and realm from the WWW-Authenticate header of the completed response. Exclude Authorization headers, usernames, passwords, and full URLs containing sensitive query values.
Does a 403 response mean the HTTP password was rejected?
Not by itself. A 403 means the server understood the request and refuses it; many systems use that status for insufficient permission after authentication, but the status does not prove which identity check ran.
RELATED GUIDES
Continue the learning route
GUIDE 01
Test Browser Cache Behavior with Selenium BiDi
Learn Selenium BiDi browser cache testing with working code, failure cases, debugging steps, and CI evidence for reliable QA automation in practice.
GUIDE 02
Test Browsing Context Tree Mutations with Selenium BiDi
Master Selenium BiDi browsing context tree mutation testing with implementation examples, failure analysis, evidence design, CI controls, and release-ready QA checklists.
GUIDE 03
Selenium BiDi Script and Browsing Context Testing Guide
A practical guide to Selenium BiDi script browsing context testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 04
Test Selenium Manager Cache TTL Invalidation
Master Selenium Manager cache TTL invalidation testing with implementation examples, failure analysis, evidence design, CI controls, and release-ready QA checklists.
GUIDE 05
Intercept Requests and Supply Authentication with Selenium BiDi Network
Use Selenium BiDi network intercepts for scoped Basic or Digest authentication, request control, response evidence, cleanup, and safe diagnostics.