Chapter 23Lesson 02~260 minutes

Authentication Flows, Proxies, Certificates, and Enterprise Browser Environments: Guided Hands-On Workflow

This workflow builds a disposable local authentication service, a deliberately constrained forward proxy, and a certificate-trust fixture. The goal is to observe which layer changes after each action, not merely to make a login succeed.

Loopback AUTSynthetic loginManual proxyLocal CARedacted evidence

Learning objectives

  • Generate and start a disposable authentication AUT and constrained local proxy.
  • Configure a Selenium browser session with an explicit standard Proxy capability and verify the route from DOM evidence.
  • Verify authenticated state and cookie flags without saving credential or cookie values.
  • Create a local CA/server certificate fixture and prove trusted-versus-untrusted transport behavior without globally disabling TLS.
  • Choose a safe SSO/MFA testing strategy when the real identity system is outside Selenium ownership.

1. Preflight and ownership map

Mandatory path: Python 3.10+, selenium==4.47.0, a local Chromium-family browser, Selenium Manager, and two loopback ports: AUT 8783 and proxy 8899. The example uses Chromium’s --proxy-bypass-list=<-loopback> only to force this artificial loopback lab through the explicit proxy; that flag is browser-specific and is not a portable Selenium capability.

The fixture credential is intentionally fake: learner@example.test / LabOnly-2026!. It must never be replaced with a real account. The proxy only routes auth.lab.test:8783 to loopback and strips proxy-authorization from forwarding/logging.

Disposable target only

Do not bind these lab services to public interfaces, put real credentials into the fixture, or point the proxy at arbitrary internet destinations.

2. Generate the authentication and proxy fixture

The following example makes the Generate the authentication and proxy fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from pathlib import Path

root = Path("selenium-auth-lab")
root.mkdir(exist_ok=True)

(root / "auth_server.py").write_text('from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler\nfrom http.cookies import SimpleCookie\nfrom urllib.parse import parse_qs\nimport secrets\n\nHOST, PORT = "127.0.0.1", 8783\nUSER = "learner@example.test"\nPASSWORD = "LabOnly-2026!"  # synthetic fixture credential only\nSESSIONS = set()\n\nLOGIN = b"""<!doctype html><html><head><title>Auth Lab</title></head><body>\n<h1>Authorized test login</h1><form method=\'post\' action=\'/login\'>\n<label>Email <input id=\'email\' name=\'email\' autocomplete=\'username\'></label>\n<label>Password <input id=\'password\' name=\'password\' type=\'password\' autocomplete=\'current-password\'></label>\n<button id=\'sign-in\' type=\'submit\'>Sign in</button></form>\n<p id=\'route\'>ROUTE</p><p id=\'message\'></p></body></html>"""\n\ndef cookie_token(raw):\n    jar = SimpleCookie(); jar.load(raw or "")\n    morsel = jar.get("lab_session")\n    return morsel.value if morsel else None\n\nclass Handler(BaseHTTPRequestHandler):\n    def _via(self): return self.headers.get("X-Lab-Via-Proxy", "no")\n    def _send(self, status, body, headers=()):\n        self.send_response(status)\n        self.send_header("Content-Type", "text/html; charset=utf-8")\n        self.send_header("Cache-Control", "no-store")\n        for k, v in headers: self.send_header(k, v)\n        self.end_headers(); self.wfile.write(body)\n    def do_GET(self):\n        if self.path == "/health":\n            return self._send(200, b"ok")\n        if self.path == "/login":\n            return self._send(200, LOGIN.replace(b"ROUTE", f"proxy={self._via()}".encode()))\n        if self.path == "/dashboard":\n            token = cookie_token(self.headers.get("Cookie"))\n            if token not in SESSIONS:\n                return self._send(401, b"<h1 id=\'state\'>not-authenticated</h1>")\n            body = f"""<h1 id=\'state\'>authenticated</h1><p id=\'user\'>{USER}</p>\n            <p id=\'route\'>proxy={self._via()}</p><a id=\'logout\' href=\'/logout\'>Logout</a>""".encode()\n            return self._send(200, body)\n        if self.path == "/logout":\n            token = cookie_token(self.headers.get("Cookie")); SESSIONS.discard(token)\n            return self._send(200, b"<h1 id=\'state\'>logged-out</h1>", [\n                ("Set-Cookie", "lab_session=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax")])\n        return self._send(404, b"not found")\n    def do_POST(self):\n        if self.path != "/login": return self._send(404, b"not found")\n        length = int(self.headers.get("Content-Length", "0"))\n        form = parse_qs(self.rfile.read(length).decode())\n        if form.get("email", [""])[0] != USER or form.get("password", [""])[0] != PASSWORD:\n            return self._send(401, b"<h1 id=\'state\'>invalid-credentials</h1>")\n        token = secrets.token_urlsafe(24); SESSIONS.add(token)\n        self.send_response(303)\n        self.send_header("Location", "/dashboard")\n        self.send_header("Set-Cookie", f"lab_session={token}; Path=/; HttpOnly; SameSite=Lax")\n        self.send_header("Cache-Control", "no-store")\n        self.end_headers()\n    def log_message(self, fmt, *args):\n        # Do not log request bodies, Cookie, Authorization, or proxy credentials.\n        print("AUTH", self.command, self.path.split("?", 1)[0])\n\nThreadingHTTPServer((HOST, PORT), Handler).serve_forever()\n', encoding="utf-8")

(root / "proxy_server.py").write_text('from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler\nfrom urllib.parse import urlsplit\nimport http.client\n\nHOST, PORT = "127.0.0.1", 8899\nUPSTREAM_HOST, UPSTREAM_PORT = "127.0.0.1", 8783\nALLOWED_HOST = "auth.lab.test"\nHOP = {"connection", "proxy-connection", "keep-alive", "transfer-encoding", "upgrade"}\n\nclass Proxy(BaseHTTPRequestHandler):\n    def _forward(self):\n        target = urlsplit(self.path)\n        if target.scheme != "http" or target.hostname != ALLOWED_HOST or (target.port or 80) != UPSTREAM_PORT:\n            self.send_error(502, "lab proxy only routes auth.lab.test:8783"); return\n        body = None\n        if self.command in {"POST", "PUT", "PATCH"}:\n            body = self.rfile.read(int(self.headers.get("Content-Length", "0")))\n        path = target.path or "/"\n        if target.query: path += "?" + target.query\n        headers = {k:v for k,v in self.headers.items() if k.lower() not in HOP and k.lower() not in {"proxy-authorization", "cookie", "host"}}\n        if self.headers.get("Cookie"): headers["Cookie"] = self.headers["Cookie"]\n        headers["Host"] = f"{ALLOWED_HOST}:{UPSTREAM_PORT}"\n        headers["X-Lab-Via-Proxy"] = "yes"\n        conn = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=5)\n        conn.request(self.command, path, body=body, headers=headers)\n        resp = conn.getresponse(); payload = resp.read()\n        self.send_response(resp.status)\n        for k,v in resp.getheaders():\n            if k.lower() not in HOP: self.send_header(k, v)\n        self.end_headers(); self.wfile.write(payload); conn.close()\n        print("PROXY", self.command, target.path)\n    do_GET = _forward\n    do_POST = _forward\n    def log_message(self, fmt, *args): pass\n\nThreadingHTTPServer((HOST, PORT), Proxy).serve_forever()\n', encoding="utf-8")

(root / "selenium_login.py").write_text('import json, os\nfrom pathlib import Path\nfrom selenium import webdriver\nfrom selenium.webdriver.common.by import By\nfrom selenium.webdriver.common.proxy import Proxy\nfrom selenium.webdriver.support.ui import WebDriverWait\nfrom selenium.webdriver.support import expected_conditions as EC\n\nUSER = os.environ.get("SEL_LAB_USER", "learner@example.test")\nPASSWORD = os.environ.get("SEL_LAB_PASSWORD", "LabOnly-2026!")\nBASE = "http://auth.lab.test:8783"\nART = Path("artifacts"); ART.mkdir(exist_ok=True)\n\nproxy = Proxy({"proxyType":"manual", "httpProxy":"127.0.0.1:8899", "sslProxy":"127.0.0.1:8899"})\noptions = webdriver.ChromeOptions()\noptions.proxy = proxy\noptions.add_argument("--proxy-bypass-list=<-loopback>")\noptions.add_argument("--headless=new")\n\ndriver = webdriver.Chrome(options=options)\ntry:\n    driver.get(BASE + "/login")\n    assert driver.find_element(By.ID, "route").text == "proxy=yes"\n    driver.find_element(By.ID, "email").send_keys(USER)\n    driver.find_element(By.ID, "password").send_keys(PASSWORD)\n    driver.find_element(By.ID, "sign-in").click()\n    WebDriverWait(driver, 5).until(EC.text_to_be_present_in_element((By.ID, "state"), "authenticated"))\n    assert driver.find_element(By.ID, "user").text == USER\n    cookie = driver.get_cookie("lab_session")\n    assert cookie and cookie.get("httpOnly") is True\n    evidence = {\n        "session_id": driver.session_id,\n        "browser": driver.capabilities.get("browserName"),\n        "browser_version": driver.capabilities.get("browserVersion"),\n        "url": driver.current_url,\n        "title": driver.title,\n        "proxy": driver.capabilities.get("proxy"),\n        "cookie": {k: cookie.get(k) for k in ("name", "domain", "path", "httpOnly", "secure", "sameSite")},\n    }\n    (ART / "manifest.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8")\n    driver.save_screenshot(str(ART / "authenticated.png"))\n    driver.find_element(By.ID, "logout").click()\n    WebDriverWait(driver, 5).until(EC.text_to_be_present_in_element((By.ID, "state"), "logged-out"))\nfinally:\n    driver.quit()\n', encoding="utf-8")

print(root.resolve())

Run the generator once. It writes three files under selenium-auth-lab/. The AUT stores only random in-memory session IDs, the proxy allow-lists one synthetic hostname, and logs exclude bodies, cookies, Authorization, and proxy-authorization values.

python make_auth_lab.py
# terminal 1
python selenium-auth-lab/auth_server.py
# terminal 2
python selenium-auth-lab/proxy_server.py
# preflight from the runner, before opening a browser
python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8783/health').read().decode())"

3. Inspect requested proxy configuration before browser creation

The standard Selenium Proxy object belongs to the WebDriver session request. It is distinct from Python process environment variables such as HTTP_PROXY and from enterprise PAC/system policy.

from selenium import webdriver
from selenium.webdriver.common.proxy import Proxy

proxy = Proxy({
    "proxyType": "manual",
    "httpProxy": "127.0.0.1:8899",
    "sslProxy": "127.0.0.1:8899",
})
options = webdriver.ChromeOptions()
options.proxy = proxy
print(options.to_capabilities().get("proxy"))
# No browser state has changed yet. This is requested configuration only.

4. Run the authorized login and verify causality

The following example makes the Run the authorized login and verify causality behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

python -m venv .venv
# Windows PowerShell: .venv\Scripts\Activate.ps1
# POSIX shell: source .venv/bin/activate
python -m pip install "selenium==4.47.0"
python selenium-auth-lab/selenium_login.py

The browser requests http://auth.lab.test:8783/login. Because this synthetic hostname has no required local DNS mapping, the explicit proxy receives the absolute HTTP request and forwards it to 127.0.0.1:8783. The proxy adds a harmless X-Lab-Via-Proxy marker, which the AUT renders as proxy=yes. That DOM text is causal evidence that the configured route was used.

The login POST changes AUT state by creating an in-memory session token and browser state by receiving an HttpOnly cookie. The test saves only cookie metadata, returned browser capabilities, session ID, URL/title, and an authenticated screenshot. Logout clears the cookie at the AUT/browser boundary before quit() ends the WebDriver session.

5. Build a disposable certificate-trust fixture

This step demonstrates correct trust instead of globally disabling verification. It requires the local openssl CLI. The CA and server key exist only under the lab directory and are deleted in cleanup.

mkdir -p selenium-auth-lab/tls
cd selenium-auth-lab/tls
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
  -keyout ca.key -out ca.crt -subj "/CN=Selenium Lab CA"
openssl req -newkey rsa:2048 -nodes \
  -keyout server.key -out server.csr -subj "/CN=127.0.0.1"
printf "subjectAltName=IP:127.0.0.1
extendedKeyUsage=serverAuth
" > server.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out server.crt -days 1 -extfile server.ext
cd ../..

The following example makes the Build a disposable certificate-trust fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

# selenium-auth-lab/tls_server.py
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
import ssl
from pathlib import Path

root = Path(__file__).parent / "tls"
class H(BaseHTTPRequestHandler):
    def do_GET(self):
        body = b"tls-ok" if self.path == "/health" else b"not-found"
        self.send_response(200 if self.path == "/health" else 404)
        self.end_headers(); self.wfile.write(body)
    def log_message(self, *args): pass

server = ThreadingHTTPServer(("127.0.0.1", 9443), H)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(root / "server.crt", root / "server.key")
server.socket = ctx.wrap_socket(server.socket, server_side=True)
server.serve_forever()

With the HTTPS server running, a default TLS client should reject the private CA; a client configured to trust ca.crt should succeed. This proves the difference between trusting the intended test issuer and turning verification off.

from pathlib import Path
import ssl, urllib.request

lab = Path("selenium-auth-lab/tls")
trusted = ssl.create_default_context(cafile=str(lab / "ca.crt"))
with urllib.request.urlopen("https://127.0.0.1:9443/health", context=trusted, timeout=3) as response:
    print(response.status, response.read().decode())

For a real browser, install the lab CA only into a disposable test profile/trust store using the browser/OS-supported administration mechanism for that platform, then delete that profile. The exact trust-store tool differs across Firefox, Chromium/OS, managed desktops, containers, and CI images, so Selenium should not disguise it as a portable API. A separate session using acceptInsecureCerts can be useful for a narrowly scoped negative/test environment, but it is intentionally not the preferred mandatory trust path.

6. SSO and MFA: choose a supported test contract

If the application normally redirects to an enterprise IdP, keep the browser flow authorized by using one of these approved patterns: a dedicated non-production IdP tenant with synthetic accounts, an IdP-provided test policy/factor, or a local mock that implements only the application callback contract. The mock is for application integration tests; it does not prove the production identity provider itself.

Do not scrape a real authenticator app, intercept production OTP delivery, disable MFA, or replay someone else’s authenticated profile. Those actions change the security control rather than test it.

7. Challenge: pick the control from the layer

Your browser can load the login page through the proxy and the certificate is trusted, but after submission the application redirects back to /login?reason=tenant. What should you change first?

Answer before revealing below: not the Selenium proxy and not TLS. Preserve the redirect/DOM/cookie evidence and inspect the synthetic tenant/account/application configuration. Routing and trust already succeeded.

Knowledge checks

Answer from the operating model, then reveal the explanation.

Why does the lab use auth.lab.test instead of navigating directly to 127.0.0.1 through the proxy?

What state changes when login succeeds?

Why is cookie metadata saved but not the cookie value?

What does trusting ca.crt demonstrate that “verify=False” would not?

What is the correct mandatory response when real enterprise MFA blocks unattended automation?

Summary and next bridge

  • The AUT, proxy, browser cookie store, and TLS trust store are separately observable.
  • Synthetic login and redacted evidence keep the lab safe.
  • A local CA demonstrates correct trust rather than global TLS disablement.
  • SSO/MFA testing requires an approved identity contract.

Lesson 3 turns these mechanics into design decisions for maintainable enterprise suites.

Next lesson

Authentication Flows, Proxies, Certificates, and Enterprise Browser Environments: Configuration, Design Patterns, and Trade-Offs

Continue with Authentication Flows, Proxies, Certificates, and Enterprise Browser Environments: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

Primary references and version notes

Version baseline — August 2026

The mandatory examples pin selenium==4.47.0 and Python 3.10+. Selenium Manager remains the normal local driver-resolution path. Browser/enterprise policy and trust-store procedures are platform-managed state and are intentionally not hidden inside Selenium helpers.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.