PRACTICAL GUIDE / browser session rotation testing
Prove a login cannot inherit an attacker-known session
Build a Selenium check that catches session fixation by proving login replaces the guest cookie and the attacker-known value cannot access the account.
In this guide6 sections
- Define the attack you are actually trying to reproduce
- Observe the intended cookie without turning logs into credentials
- Prove both replacement and rejection in one attempt
- Tell a fixation defect from failures that only look like one
- Roll the check into CI without teaching retries to hide it
- Fix the server boundary and know when this browser test is the wrong tool
What you will learn
- Define the attack you are actually trying to reproduce
- Observe the intended cookie without turning logs into credentials
- Prove both replacement and rejection in one attempt
- Tell a fixation defect from failures that only look like one
A customer signs in successfully, yet the cookie in their browser keeps the exact value it had on the login page. The account screen looks normal, so the usual login test stays green. An attacker who knew that guest value before sign-in may now be holding a working key to the customer's account.
Define the attack you are actually trying to reproduce
Session fixation starts before authentication. The attacker first obtains a session identifier that the application recognizes as a guest session. Through some separate delivery weakness, the attacker causes a victim's browser to use that known identifier. The victim then signs in. If the server attaches the authenticated identity to the same identifier, the attacker can send the identifier from another browser and act as the victim.
That is the explicit threat model for this test. The attacker can acquire a legitimate anonymous identifier and can plant it in, or otherwise make it reach, the victim's browser before login. The attacker does not know the password, does not control the victim's browser after login, and does not steal a newly issued authenticated cookie. The application uses a browser cookie as at least one part of its session decision. The protected resource used as the oracle is harmless test data in a non-production environment.
The model deliberately excludes several nearby problems. Stealing an authenticated cookie after login is session hijacking, not the pre-authentication fixation sequence under test. Cross-site request forgery can make a signed-in browser perform an action without revealing its session identifier. Cross-site scripting can expose or abuse browser state through a different path. Broken object-level authorization can let one valid user read another user's record even when every session rotates correctly. Those risks deserve tests, but combining them here makes a failure impossible to classify.
OWASP's session fixation test describes the vulnerable pattern as preserving session-cookie values across authentication. Its remediation is specific: invalidate the existing identifier and provide another after successful authentication. The broader Session Management Cheat Sheet section "Renew the Session ID After Any Privilege Level Change" requires renewal when privilege changes, with authentication as the common case, and previous identifiers must not be accepted for sensitive pages.
A cookie-backed application needs three independent security claims from this test. First, the authenticated browser must not keep the attacker-known identifier. Second, the attacker-known identifier must not authorize a protected request after the victim signs in. Third, the victim's new session must still work. Removing any one of those claims creates a false pass.
Consider a shopping site that gives every visitor a guest session for a cart. An attacker creates cart session A, plants A in a victim browser, and waits. The victim signs in. A correct server can copy safe cart references into a new authenticated session B, invalidate A for authenticated access, and send B to the victim. The victim can still see the cart and account, while a browser carrying A remains a guest or receives a denial. A vulnerable server simply changes the server-side owner attached to A. Both implementations can display the same successful account page to the victim.
A second implementation may use one cookie name before and after login but generate a new value. Another may use a guest cookie and a different authenticated cookie. Different names make a name comparison irrelevant, but they do not make value reuse safe. The authenticated credential must differ from the attacker-known guest value, and replaying that known value under the authenticated cookie name and configured scope must not authorize the account. OWASP lists separate pre-authentication and post-authentication token names as a complementary design, not as permission to skip renewal or old-value rejection.
Write the expected result in authorization terms instead of guessing one HTTP status. Some products redirect an unauthenticated browser to the login page. An API may return an authentication failure. A server-rendered application may show a signed-out shell with a sign-in form. The test should recognize the product's documented denial state and make a fresh protected request. It should not demand a particular response code unless that response is part of the contract.
The protected action must reach the server. A page restored from the back-forward cache or a screenshot of account content proves only that the browser can display old pixels. A useful fixture exposes a harmless account page or identity probe with mutually exclusive authenticated and denied markers. That fixture must apply the same authorization middleware as a real protected route. A public page containing the user's display name is not an authorization oracle.
The attacker-known value is sensitive even though it begins as anonymous. It may hold a cart, workflow state, or a path into the very vulnerability being tested. Capture it only in an isolated test system. Do not paste it into a bug report, record it in a video, or put it in a URL. The test needs the raw value briefly in process memory because it must plant and replay it, but no person needs to see it.
Observe the intended cookie without turning logs into credentials
Selenium provides commands to add, read, and delete cookies in the current browser context. The official cookie interaction examples navigate to a site before adding a cookie. The WebDriver specification defines an invalid cookie domain error for an attempt to set a cookie under a different domain from the current page. In practice, start on the target HTTPS origin, clear the new browser's cookies, then add the attacker-known value.
The phrase "the session cookie" is often too vague for automation. Applications can set two cookies with the same name on different paths or subdomains. If a test calls a named-cookie helper without checking those configured fields, it can compare whichever matching cookie the browser returns and announce a rotation that never occurred on the credential actually sent to the account route.
Document four fields before writing the assertion: the guest cookie name, the authenticated cookie name, the expected domain text, and the expected path. Navigate to a URL where that cookie is applicable, call Selenium's all-cookies command, and select the configured name, normalized domain text, and path. Fail when the selection returns zero or more than one candidate. Quietly choosing the first candidate turns a configuration problem into misleading security evidence.
WebDriver's serialized cookie does not expose a portable host-only flag, and these examples normalize away a leading dot in the domain text. They therefore do not distinguish a host-only cookie from a Domain cookie that WebDriver serializes with the same domain text. If that distinction is part of the security contract, verify it with an approved Set-Cookie capture or a lower-level test that preserves the attribute instead of claiming that this Selenium selector proved it.
This diagnostic script prints cookie metadata and a short keyed fingerprint. It never prints a value. The HMAC key exists only for that process, so the fingerprint is useful for equality inside one run but cannot be correlated across builds. Put the script next to the security tests and run it against a dedicated environment, not a customer session. It builds its browser through the same helper the test module uses, so the HEADLESS variable is honoured in both places. That detail matters because the CI job later in this article sets HEADLESS to 1 and runs this script as the first browser step of the job. A diagnostic that called webdriver.Firefox() directly would ignore the variable and try to open a window on a runner that has no display.
import hashlib
import hmac
import json
import os
import secrets
from urllib.parse import urlparse
from selenium import webdriver
BASE_URL = os.environ["BASE_URL"].rstrip("/")
PROBE_URL = os.getenv("SESSION_PRIMER_URL", f"{BASE_URL}/login")
COOKIE_NAME = os.getenv("GUEST_COOKIE_NAME", "session")
COOKIE_DOMAIN = os.getenv(
"SESSION_COOKIE_DOMAIN",
urlparse(BASE_URL).hostname or "",
).lstrip(".").lower()
COOKIE_PATH = os.getenv("SESSION_COOKIE_PATH", "/")
COOKIE_SCOPE_URL = os.getenv(
"SESSION_COOKIE_SCOPE_URL",
f"{BASE_URL}{COOKIE_PATH}",
)
RUN_KEY = secrets.token_bytes(32)
def fingerprint(value: str) -> str:
return hmac.new(
RUN_KEY,
value.encode("utf-8"),
hashlib.sha256,
).hexdigest()[:12]
def canonical_domain(value: str | None) -> str:
return (value or "").lstrip(".").lower()
def safe_scope(cookie: dict) -> dict:
return {
"name": cookie["name"],
"domain": cookie.get("domain"),
"path": cookie.get("path", "/"),
"secure": bool(cookie.get("secure", False)),
"httpOnly": bool(cookie.get("httpOnly", False)),
"sameSite": cookie.get("sameSite"),
"persistent": "expiry" in cookie,
}
def build_browser():
options = webdriver.FirefoxOptions()
if os.getenv("HEADLESS", "1") == "1":
options.add_argument("-headless")
return webdriver.Firefox(options=options)
driver = build_browser()
try:
driver.get(PROBE_URL)
driver.get(COOKIE_SCOPE_URL)
candidates = [
cookie
for cookie in driver.get_cookies()
if cookie["name"] == COOKIE_NAME
and canonical_domain(cookie.get("domain")) == COOKIE_DOMAIN
and cookie.get("path", "/") == COOKIE_PATH
]
report = {
"candidateCount": len(candidates),
"scopes": [safe_scope(cookie) for cookie in candidates],
"selectedFingerprint": (
fingerprint(candidates[0]["value"])
if len(candidates) == 1
else None
),
}
print(json.dumps(report, indent=2, sort_keys=True))
if len(candidates) != 1:
raise SystemExit("Expected exactly one cookie at the configured scope")
finally:
driver.quit()A candidate count of zero is not automatically a product bug. The application may create a guest session only after a cart action, consent choice, or anti-CSRF bootstrap. Point SESSION_PRIMER_URL at the smallest route that legitimately creates the session. COOKIE_SCOPE_URL defaults to a URL at SESSION_COOKIE_PATH; set it explicitly when the path needs a stable probe route that does not redirect elsewhere. If the product has no anonymous session at all, change the scenario rather than manufacturing a contract it does not have.
More than one candidate usually means the configured scope is incomplete or the app left duplicate cookies. Record names, domains, paths, and flags, but keep values out of the output. Then inspect the browser's storage view and the server's session configuration. If only one of the cookies is sent to the protected route because of path matching, run the probe from that route's path before deciding which credential matters.
The Secure, HttpOnly, and SameSite attributes answer different questions. MDN's Set-Cookie reference explains that HttpOnly prevents page JavaScript from reading a cookie, while the browser still sends it with requests. Secure limits transmission to secure contexts, subject to browser handling documented by the platform. SameSite controls specified cross-site sending behavior. None of those attributes changes an old identifier into a new one.
WebDriver is not page JavaScript. Its cookie commands can inspect browser cookie state for automation, including metadata that document.cookie cannot expose for an HttpOnly cookie. That access is precisely why driver command logs need the same protection as credentials. Do not enable verbose remote-driver payload logging for this case unless the resulting artifact is access-controlled, short-lived, and reviewed for raw cookie values.
Selenium shows the cookie state stored after navigation. That state does not identify which response issued each Set-Cookie header or whether an intermediate redirect wrote and replaced a value. When timing matters, correlate the browser observation with protected server events or with an already approved network-capture facility. Do not infer a response sequence from one final cookie snapshot.
A safe diagnostic record includes the run ID, application build, browser build, transition name, configured scope, pre-login fingerprint, post-login fingerprint, and protected-access outcomes. The run ID must be a random label unrelated to any session. OWASP recommends logging session lifecycle events but warns against logging the raw identifier; its cheat sheet suggests a protected correlation representation rather than the reusable value itself.
Prove both replacement and rejection in one attempt
A realistic fixation test uses separate browser profiles for the attacker and victim. The attacker browser asks the application for a legitimate guest session and retains that value. The victim browser visits the configured cookie-scope URL so WebDriver has a valid domain and path context, deletes the session it just received, and receives the attacker-known guest cookie through Selenium. The victim browser then reloads the scope URL once, before login, to confirm the application did not immediately replace the planted identifier. After the victim signs in, the test checks the authenticated cookie and makes a fresh protected request in the victim browser. It then clears the attacker browser and replays the known value through every credential channel the application recognizes, one channel at a time and finally all of them together, making a separate protected request for each.
The following pytest module is complete code for an application that exposes stable test selectors. Configure the URLs, selectors, cookie scope, and credentials for your fixture. The authenticated marker and denial marker must be mutually exclusive and must appear only after the protected request has reached the application's authorization layer. The protected route must also deny an ordinary guest session, because a page that serves anonymous visitors cannot distinguish acceptance of the attacker-known identifier from a route that was never protected.
import hashlib
import hmac
import os
import secrets
from urllib.parse import urlparse
import pytest
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
BASE_URL = os.environ["BASE_URL"].rstrip("/")
LOGIN_URL = os.getenv("LOGIN_URL", f"{BASE_URL}/login")
SESSION_PRIMER_URL = os.getenv("SESSION_PRIMER_URL", LOGIN_URL)
PROTECTED_URL = os.getenv("PROTECTED_URL", f"{BASE_URL}/account")
GUEST_COOKIE_NAME = os.getenv("GUEST_COOKIE_NAME", "session")
AUTH_COOKIE_NAME = os.getenv("AUTH_COOKIE_NAME", GUEST_COOKIE_NAME)
COOKIE_DOMAIN = os.getenv(
"SESSION_COOKIE_DOMAIN",
urlparse(BASE_URL).hostname or "",
).lstrip(".").lower()
COOKIE_PATH = os.getenv("SESSION_COOKIE_PATH", "/")
COOKIE_SCOPE_URL = os.getenv(
"SESSION_COOKIE_SCOPE_URL",
f"{BASE_URL}{COOKIE_PATH}",
)
EMAIL_SELECTOR = os.getenv("EMAIL_SELECTOR", "[data-testid='email']")
PASSWORD_SELECTOR = os.getenv("PASSWORD_SELECTOR", "[data-testid='password']")
SUBMIT_SELECTOR = os.getenv("SUBMIT_SELECTOR", "[data-testid='sign-in']")
AUTH_MARKER = os.getenv("AUTH_MARKER", "[data-testid='account-home']")
DENIED_MARKER = os.getenv(
"DENIED_MARKER",
"[data-testid='sign-in-form']",
)
WAIT_SECONDS = int(os.getenv("SESSION_TEST_WAIT_SECONDS", "15"))
RUN_KEY = secrets.token_bytes(32)
def build_browser():
options = webdriver.FirefoxOptions()
if os.getenv("HEADLESS", "1") == "1":
options.add_argument("-headless")
return webdriver.Firefox(options=options)
def canonical_domain(value):
return (value or "").lstrip(".").lower()
def cookie_at_scope(driver, name):
candidates = [
cookie
for cookie in driver.get_cookies()
if cookie["name"] == name
and canonical_domain(cookie.get("domain")) == COOKIE_DOMAIN
and cookie.get("path", "/") == COOKIE_PATH
]
if len(candidates) > 1:
scopes = [
{
"name": cookie["name"],
"domain": cookie.get("domain"),
"path": cookie.get("path", "/"),
}
for cookie in candidates
]
pytest.fail(
f"Multiple configured session cookies were visible: {scopes}",
pytrace=False,
)
return candidates[0] if candidates else None
def require_cookie(driver, name):
cookie = cookie_at_scope(driver, name)
if cookie is None:
pytest.fail(
f"No {name!r} cookie existed at the configured scope",
pytrace=False,
)
return cookie
def wait_for_cookie(driver, name):
return WebDriverWait(driver, WAIT_SECONDS).until(
lambda current: cookie_at_scope(current, name) or False
)
def fingerprint(value):
return hmac.new(
RUN_KEY,
value.encode("utf-8"),
hashlib.sha256,
).hexdigest()[:12]
def plant_known_cookies(driver, entries):
driver.get(COOKIE_SCOPE_URL)
driver.delete_all_cookies()
for name, value, template_cookie in entries:
cookie = {
"name": name,
"value": value,
"path": COOKIE_PATH,
"secure": bool(template_cookie.get("secure", False)),
}
if template_cookie.get("domain"):
cookie["domain"] = template_cookie["domain"]
same_site = template_cookie.get("sameSite")
if same_site and (same_site != "None" or cookie["secure"]):
cookie["sameSite"] = same_site
try:
driver.add_cookie(cookie)
except WebDriverException:
pytest.fail(
f"WebDriver could not plant {name!r} on the current origin; "
"check BASE_URL and the configured cookie scope",
pytrace=False,
)
return {
name: require_cookie(driver, name)
for name, _value, _template in entries
}
def confirm_planted_value_survives(driver, name, value):
driver.get(COOKIE_SCOPE_URL)
stored = cookie_at_scope(driver, name)
observed = fingerprint(stored["value"]) if stored else "absent"
if stored is None or stored["value"] != value:
pytest.fail(
f"The application replaced the planted {name!r} identifier before "
"login, so this fixation sequence cannot be reproduced through "
f"that cookie: planted={fingerprint(value)} observed={observed}",
pytrace=False,
)
def replay_channels(known_value, guest_template, auth_template):
guest_channel = [(GUEST_COOKIE_NAME, known_value, guest_template)]
if AUTH_COOKIE_NAME == GUEST_COOKIE_NAME:
return [guest_channel]
auth_channel = [(AUTH_COOKIE_NAME, known_value, auth_template)]
return [auth_channel, guest_channel, auth_channel + guest_channel]
def sign_in(driver):
driver.get(LOGIN_URL)
driver.find_element(By.CSS_SELECTOR, EMAIL_SELECTOR).send_keys(
os.environ["QA_USER_EMAIL"]
)
driver.find_element(By.CSS_SELECTOR, PASSWORD_SELECTOR).send_keys(
os.environ["QA_USER_PASSWORD"]
)
driver.find_element(By.CSS_SELECTOR, SUBMIT_SELECTOR).click()
WebDriverWait(driver, WAIT_SECONDS).until(
lambda current: current.find_elements(By.CSS_SELECTOR, AUTH_MARKER)
or False
)
def protected_outcome(driver):
separator = "&" if "?" in PROTECTED_URL else "?"
probe = secrets.token_hex(8)
driver.get(f"{PROTECTED_URL}{separator}qa_probe={probe}")
def resolved(current):
if current.find_elements(By.CSS_SELECTOR, AUTH_MARKER):
return "authenticated"
if current.find_elements(By.CSS_SELECTOR, DENIED_MARKER):
return "denied"
return False
return WebDriverWait(driver, WAIT_SECONDS).until(resolved)
def test_login_rejects_an_attacker_known_guest_session():
attacker = build_browser()
try:
victim = build_browser()
except Exception:
attacker.quit()
raise
try:
attacker.get(SESSION_PRIMER_URL)
attacker.get(COOKIE_SCOPE_URL)
known_guest = require_cookie(attacker, GUEST_COOKIE_NAME)
known_value = known_guest["value"]
plant_known_cookies(
victim,
[(GUEST_COOKIE_NAME, known_value, known_guest)],
)
confirm_planted_value_survives(victim, GUEST_COOKIE_NAME, known_value)
sign_in(victim)
victim.get(COOKIE_SCOPE_URL)
authenticated = wait_for_cookie(victim, AUTH_COOKIE_NAME)
assert known_value != authenticated["value"], (
"Login kept the attacker-known session identifier: "
f"known={fingerprint(known_value)} "
f"authenticated={fingerprint(authenticated['value'])}"
)
assert protected_outcome(victim) == "authenticated", (
"The newly authenticated browser could not reach protected content"
)
for channel in replay_channels(known_value, known_guest, authenticated):
names = ", ".join(name for name, _value, _template in channel)
plant_known_cookies(attacker, channel)
assert protected_outcome(attacker) == "denied", (
"The attacker-known value replayed under "
f"{names} reached protected content: "
f"known={fingerprint(known_value)}"
)
finally:
try:
victim.quit()
finally:
attacker.quit()Replaying the known value under one cookie name is the mistake that turns this whole test into decoration. Consider the design this article configures with separate GUEST_COOKIE_NAME and AUTH_COOKIE_NAME values. A vulnerable server can issue a brand-new authenticated cookie at login and still leave session A authorized under the guest name, because the code that upgraded the session never revoked the old mapping. A replay that plants A only under the authenticated name never sends the guest cookie at all, since the planting step clears the browser first. The equality assertion then passes because the two values differ, and the protected request is denied because the server does not recognize A under the authenticated name. Both assertions report green while the victim's account is still reachable with a value the attacker chose before login. The loop over channels closes that gap by probing the authenticated name, then the guest name, then both cookies present at once, and requiring denial from each. When the product uses one name for both roles, there is one channel and the loop runs once.
Two details of the replay are deliberate. The planting helper clears the browser before each channel, so a cookie left over from the previous probe cannot answer for the current one. Nothing navigates between planting and the protected request, because that request is the navigation that carries the replayed credential. A warm-up page load inserted there would give the server a chance to hand the attacker browser a fresh guest cookie, after which a denial would prove only that a new anonymous session cannot read the account.
One field needs care when the observed cookie is copied into the planted one. Firefox's WebDriver serializes a cookie that arrived with no SameSite attribute as a sameSite value of None, and the browser then refuses to store a cookie that declares SameSite None without the Secure flag. Copying the field back unconditionally therefore fails on any fixture whose session cookie is neither Secure nor explicitly SameSite-scoped, and it fails inside the planting helper with a message that blames BASE_URL and the cookie scope. The helper copies the observed value only when it is not None or the cookie is Secure. Keep the same rule if you extend the harness to another browser, and treat an unexpected planting error as a harness bug until the driver message says otherwise.
The pre-login retention check is the one place where a navigation between writing and reading the cookie is meaningful. Reading a cookie back immediately after add_cookie returns the value just written, so an equality comparison there can never fail and proves nothing about the product. What the helper does check immediately is scope: require_cookie selects by configured name, domain, and path, so a cookie the browser stored somewhere else is reported as missing. The separate reload before login asks a different question that the application can genuinely answer differently. A server that treats an unrecognized identifier as untrusted and issues its own replacement defeats fixation by construction, and the run stops with a message saying the sequence cannot be reproduced through that cookie rather than claiming a vulnerability or a pass.
Every assertion can fail for a different reason. A domain or path mistake in the harness stops the run inside require_cookie with the message that no cookie of that name existed at the configured scope, which is a harness or configuration failure reported before the security transition is evaluated. If the login handler preserves the planted value under either the guest name or a different authenticated name, the unconditional equality assertion fails. If the server sends a new value but still accepts the attacker-known value through any credential channel, the attacker browser reaches the authenticated marker and that channel's replay assertion fails, naming the cookie or cookies that were sent. If session regeneration destroys state without establishing the replacement correctly, the victim cannot reach protected content.
Do not reverse the last two assertions. A denied attacker is not enough if the victim is also denied. That outcome means the sign-in flow broke, the marker is wrong, or the server invalidated both sessions. It is not evidence of a safe login.
The code keeps raw values only in process memory until both drivers quit. The second browser is created inside its own guard so that a failure to start it still quits the first one, and the nested cleanup quits the attacker browser even when quitting the victim raises. A leaked headless Firefox on a shared runner is not only wasted memory; it is an authenticated session left alive after the test that was supposed to own it has ended. Assertion messages contain keyed fingerprints generated with a per-process key. Standard pytest output therefore has enough information to show equality without printing a reusable credential. Check any driver-service logs separately, because a sufficiently verbose WebDriver transport log may record command payloads outside the test's own print statements.
With the assertion text above, two failures have the following shape. The fingerprints are illustrative strings, not measurements and not values from a real system.
$ pytest -q tests/security/test_session_rotation.py
E AssertionError: Login kept the attacker-known session identifier: known=47dd98c2b013 authenticated=47dd98c2b013
$ pytest -q tests/security/test_session_rotation.py
E AssertionError: The attacker-known value replayed under guest_session reached protected content: known=1f4a789c028eThe first line points at identifier reuse across the privilege change, regardless of whether the cookie name changed. The second line points at cosmetic rotation or at acceptance of the attacker-known value through one specific credential channel, and it names the channel that accepted it. In this illustration the authenticated cookie name was denied and the guest cookie name was not, which is exactly the defect a single-channel replay reports as a pass. A timeout waiting for the authenticated marker is different evidence. It says the configured login signal never appeared within the test boundary. Check credentials, MFA, redirects, selector drift, and application health before filing a fixation bug.
Run a third case for logout, but do not fold it into the login assertion. Capture the authenticated cookie in memory, log out through the supported user flow, and replay that cookie from a separate profile. The expected denial is similar, while the transition and likely owner are different. A failure after logout belongs to session termination; a failure after login belongs to privilege-transition rotation. Separate tests give the triage team an honest component boundary.
Privilege elevation deserves another case when the product lets an existing session gain material permissions. Start as the lower role, capture the scoped authenticated identifier, perform the supported elevation, and apply the contract agreed with security engineering. OWASP recommends renewal after privilege-level changes, but the exact product transition must be real. Do not simulate elevation by editing a role field in browser storage unless that is genuinely how the product works.
Tell a fixation defect from failures that only look like one
The most common false alarm comes from comparing different configured cookie scopes. Suppose the login page at auth.example.test sets a cookie named session, while the account application at app.example.test sets another cookie with the same name. The values differ after a redirect, but the change may be a host boundary rather than session regeneration. The failure record should show both URLs, the selected domain text and path, and the application's documented ownership of each cookie. A value comparison across two scopes proves nothing. This Selenium example cannot determine whether either cookie was host-only.
The inverse can hide a real defect. A root-path cookie and an older /account cookie share a name. Selenium's named lookup returns one value, while requests to the protected path may prefer another according to cookie rules. Select from all cookies by the documented scope, navigate from a relevant path, and inspect the request at the server if ambiguity remains. Do not "fix" the test by accepting either value.
A protocol-level setup failure is easy to recognize. If code adds a cookie before visiting the application, or sets a cookie for an unrelated host, WebDriver reports the invalid cookie domain error defined by the specification. That error occurs before the application receives the proposed session. It cannot establish whether the login endpoint rotates anything. Navigate to the exact target origin, then retry the setup once the scope is corrected.
Incomplete authentication produces another near-match. A password form may submit successfully but stop at MFA, a consent page, a password-expiry prompt, or an identity-provider error. The cookie can remain anonymous because trust never increased. Look for the authenticated marker on a fresh protected request and confirm the server's login-success event. Do not file session fixation from a pre-login and post-submit equality check when no authentication transition occurred.
Single sign-on adds two session owners. The identity provider controls cookies on its own domain, and the relying application controls its local session. Selenium running on the relying application's page cannot add a cookie for the identity-provider domain without first navigating there, and the application team may not be authorized to manipulate that external session at all. Test the relying application's cookie across the callback and use a clean browser profile so an unrelated identity-provider login cannot silently reauthenticate the attacker browser.
A cached account page can make an invalid old session look valid. The strongest clue is that a navigation restores content without a matching protected server request. Add a unique non-secret probe parameter, wait for an authenticated or denied marker produced by the route, and correlate the run ID with server access logs. If no request exists, you measured presentation history. If a fresh request exists and the server maps the old fingerprint to the victim, you have evidence of continued authorization.
A changed identifier with successful old-value replay needs server-side investigation. On a multi-node application, one node may create the new session while another still accepts the old mapping. A cache may retain an authorization record after the primary session store deletes it. The login service may rotate its cookie while a gateway continues honoring a separate cookie. Record the responding node or service from protected internal logs when that metadata is already available. Do not infer replication behavior merely because repeating the test sometimes changes the result.
This is the worked example that often gets mislabeled as flaky. The victim receives B after login, and the equality assertion passes. The attacker using A reaches the account only when routed to one application node. Retrying the whole test can land on another node and turn the report green. Preserve the first attempt, its run ID, safe fingerprints, responding component, and authorization result. The security defect is that an attacker-known credential was accepted by at least one supported path, not that the test had inconsistent timing.
Natural expiry can also deny the old session. If the anonymous server-side lifetime is shorter than the login journey, replay denial may come from timeout rather than explicit transition handling. Compare the event times with the documented guest-session policy and inspect the invalidation reason in protected server telemetry. For the regression to prove login rotation rather than eventual expiry, the guest lifetime must comfortably cover the test journey, and the login lifecycle event should identify renewal or replacement.
Background renewal has a different contract from authentication. OWASP's cheat sheet discusses an optional renewal timeout that may use a short safety interval while a browser switches identifiers. That is not permission to keep a pre-authentication identifier authorized after login. It does mean a test for scheduled mid-session renewal should not blindly copy the immediate old-value rule from this login case. Name the transition in every result.
Client storage can confuse the attacker replay. A second browser might become authenticated through local storage, an identity-provider cookie, a client certificate, or ambient enterprise authentication rather than through the planted application cookie. Deleting Selenium cookies does not clear every storage mechanism or machine credential. Start genuinely isolated browser profiles, inventory each supported authentication channel, and examine which credential the server accepted. The related cookie and browser storage guide is useful when the application carries auth state outside cookies.
A good ticket includes the threat sequence, not just "cookie did not rotate." State how the attacker acquired a legitimate guest identifier, how the victim browser was given it, which real login transition occurred, which protected route accepted the old value, and which safe server events corroborate the result. Include cookie scope and keyed fingerprints. Exclude credentials, raw Cookie headers, raw Set-Cookie headers, screenshots of storage panels, and full network archives unless a restricted security workflow explicitly permits them.
Roll the check into CI without teaching retries to hide it
Begin rollout in observation mode on a dedicated security environment. Run the scope diagnostic against each supported login path and have the authentication owner confirm the selected cookie names, domain, path, denial marker, and protected route. This step catches assumptions before they become noisy gates. It also reveals applications that intentionally use separate guest and authenticated cookies.
Next, run one same-password login path with a dedicated account and two fresh browser profiles. Keep the test serial for that account. Parallel jobs can revoke, replace, or elevate the same account's sessions and create results that no single user journey caused. If the suite must run concurrently, allocate a separate account and owned test record per worker rather than sharing credentials.
Prove the oracle against a controlled defect before making it a release gate. In a local or staging-only fixture, configure the session layer to preserve the guest identifier during login, then confirm the equality assertion fails. In a separate controlled variant, issue a new cookie but leave the old mapping authorized, then confirm replay fails. Remove those variants after the checker is validated. This is not a fabricated metric; it is a mutation exercise showing that a relevant product regression can turn each assertion red.
Add the case to pull requests that change login handlers, session middleware, callback handlers, authorization gateways, or session-store code. A scheduled run can cover all supported browsers and identity routes. Keep the pull-request gate narrow enough that a security failure receives investigation rather than an automatic rerun. Any retry must create a new run ID, new browsers, and a reset account, while preserving the first attempt as a separate result.
This GitHub Actions job assumes the repository already has a current self-hosted Actions runner labelled qa-browser with Firefox and a compatible driver, plus a pinned requirements-test.txt. It deliberately runs one attempt, serializes use of the shared test account, and publishes only the JUnit report produced by the safe assertion messages.
name: session rotation security check
on:
pull_request:
paths:
- "auth/**"
- "session/**"
- "tests/security/**"
- "requirements-test.txt"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: session-rotation-shared-account
cancel-in-progress: false
jobs:
fixation-regression:
runs-on: [self-hosted, qa-browser]
timeout-minutes: 20
env:
BASE_URL: ${{ vars.SECURITY_TEST_BASE_URL }}
LOGIN_URL: ${{ vars.SECURITY_TEST_LOGIN_URL }}
SESSION_PRIMER_URL: ${{ vars.SECURITY_TEST_PRIMER_URL }}
PROTECTED_URL: ${{ vars.SECURITY_TEST_PROTECTED_URL }}
GUEST_COOKIE_NAME: ${{ vars.SECURITY_TEST_GUEST_COOKIE }}
AUTH_COOKIE_NAME: ${{ vars.SECURITY_TEST_AUTH_COOKIE }}
SESSION_COOKIE_DOMAIN: ${{ vars.SECURITY_TEST_COOKIE_DOMAIN }}
SESSION_COOKIE_PATH: "/"
QA_USER_EMAIL: ${{ secrets.SECURITY_TEST_USER_EMAIL }}
QA_USER_PASSWORD: ${{ secrets.SECURITY_TEST_USER_PASSWORD }}
HEADLESS: "1"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-test.txt
- name: Install the repository's pinned test dependencies
run: python -m pip install -r requirements-test.txt
- name: Verify the configured cookie scope
run: python tests/security/diagnose_cookie_scope.py
- name: Run the fixation regression once
run: >
python -m pytest
tests/security/test_session_rotation.py
--maxfail=1
--junitxml=reports/session-rotation.xml
- name: Preserve the redacted test result
if: always()
uses: actions/upload-artifact@v7
with:
name: session-rotation-result
path: reports/session-rotation.xml
if-no-files-found: error
retention-days: 7The cache-dependency-path line is required rather than tidy. Pip caching resolves **/requirements.txt and then **/pyproject.toml, and this repository publishes neither, so omitting the path stops the job during setup and produces a red run that never opened a browser or touched a session.
The fixed concurrency group is a real cost. Pull requests that use the same account will queue instead of running this job together. Two browser processes also consume more memory and startup time than a normal login test. Those costs buy isolation between the attacker and victim, which is the central fact the test is trying to establish.
A team with per-run accounts can remove the global queue after the provisioning and cleanup paths are reliable. The setup service should create or reset only the account owned by the current run. Cleanup should revoke that account's sessions after evidence collection and should not perform a broad session-store purge that disrupts other workers.
Do not upload screenshots, browser profiles, verbose driver logs, HAR files, or page source by default. Any one of those can contain credentials or authenticated content. Start with JUnit text that contains only the safe error and run ID. If a failure needs deeper capture, rerun in the restricted security environment under an artifact policy designed for secrets, not under the ordinary pull-request retention policy.
Treat infrastructure errors separately. A browser startup failure, DNS failure, invalid cookie domain error, or missing test selector has not exercised session rotation. Mark it as an environment or test-contract failure. A green retry does not erase it, but neither should it be reported as a confirmed vulnerability. Conversely, a protected request that accepts the attacker-known value is security evidence even if a retry on another node denies it.
Migration from an existing login suite works best in three small changes. First, extract a stable authenticated marker and protected probe from the current happy-path test. Second, add exact cookie-scope discovery and redacted fingerprints without changing the gate. Third, add the attacker browser and old-value oracle after the application owner has confirmed the denial state. This sequence keeps failures attributable and avoids replacing a familiar login check with a large opaque security scenario overnight.
Fix the server boundary and know when this browser test is the wrong tool
The durable fix lives where the application changes trust, not in Selenium. After successful authentication, use the session framework's documented regeneration facility to create a new unpredictable identifier, move only explicitly approved guest state, associate the authenticated identity with the new session, and make the previous identifier unusable for protected resources. The ordering must not expose a window where the old identifier carries the new identity. Framework and store details differ, so verify the exact operation in the documentation for the server stack rather than copying an API name from another language.
Guest-state migration is the first trade-off. A shopping application may need to retain cart item references, locale, and an in-progress checkout step. Copying the entire anonymous session object is convenient but can also carry flags that should not cross the privilege boundary. Use an allowlist of business fields, validate ownership again, and leave authentication, authorization, anti-CSRF, and risk decisions behind. More explicit mapping means more maintenance when guest features change.
Concurrent requests are the second cost. A tab may submit an anonymous request while another tab completes login. Once the old identifier is invalid for protected access, that in-flight request may be denied or may lose non-sensitive guest state. Product and engineering teams need a deliberate response, such as retrying a safe read under the new session or asking the user to reload. Keeping the old authenticated mapping alive to avoid a rough edge recreates the vulnerability.
Distributed invalidation adds operational complexity. Creating B in one store and deleting or de-authorizing A in another non-atomic step can leave a window in which both work. The correct transaction depends on the chosen session store and framework. Test at the public authorization boundary, then add store-level integration tests for the actual implementation. Do not claim atomic behavior from a browser result alone.
Separate guest and authenticated cookie names can simplify the trust boundary, but they increase configuration and cleanup work. Every protected component must consult the authenticated credential and must not accidentally treat the guest cookie as proof of identity. Logout, account switching, and incident revocation must clear or invalidate the right server-side state. Multiple cookie names are a design choice, not an automatic pass.
Cookie attributes remain necessary but separate. Secure reduces exposure over non-HTTPS transport. HttpOnly prevents page scripts from reading the cookie directly. SameSite changes cross-site sending rules. Domain and Path limit where the browser sends the cookie, although Path is not an authorization boundary. A test that checks those fields should report them as individual controls. Adding HttpOnly does not repair identifier reuse, and regeneration does not compensate for a cookie sent over an unsafe channel.
Do not run the planting and replay scenario against production. It intentionally handles reusable session values, drives real authentication, and may trigger security monitoring. Use synthetic accounts and harmless records in an environment where the team owns the data, logs, and cleanup. A production smoke test can verify login availability without manipulating attacker-known credentials.
Skip this exact same-cookie comparison when the application never creates or accepts a pre-authentication session identifier. In that design, verify that a caller-supplied value is ignored, authentication creates the documented credential, and the supplied value cannot authorize anything. Forcing a guest cookie into existence only to satisfy a generic test would test Selenium setup rather than the product.
Use a lower-level test first when the browser adds no useful evidence. A session-store integration test can exercise regeneration, invalidation, and concurrent access much faster and can force rare ordering conditions deterministically. Keep one browser path when cookie scope, redirects, or identity-provider callbacks matter. The two layers answer different questions: the integration test explains store behavior, while Selenium proves what separate browser profiles can actually send and access.
Choose another client for native applications or APIs that use an Authorization header rather than browser cookies. The security rule still concerns trust transitions and old-token authority, but Selenium's cookie commands are the wrong instrument. Likewise, do not attempt to manipulate a third-party identity provider's cookies unless the provider and test agreement explicitly allow it. Test the relying application's local session at the callback boundary.
Do not copy the immediate login oracle into a periodic renewal test without reading that feature's contract. Some mid-session renewal designs have a documented handover interval. Login is a privilege increase, so the attacker-known pre-login identifier must not become a key to protected content. A scheduled renewal while the user is already authenticated is a different transition with different concurrency behavior.
Finally, do not call this a complete session-security result. Passing shows that the tested attacker-known value did not survive the tested authentication path with protected authority, in the tested build and environment. It does not establish identifier entropy, theft resistance, CSRF protection, timeout enforcement, logout behavior, authorization correctness, or coverage of every identity route. Give each of those properties an oracle that can fail for its own reason.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
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 w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 03Official owasp.org reference
owasp.org
Primary documentation selected and verified for the claims in this guide.
- 04Official cheatsheetseries.owasp.org reference
cheatsheetseries.owasp.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
How do I check whether login rotates a session cookie?
Capture the attacker-known guest cookie in memory, sign in with that value planted in a separate browser, and compare the authenticated cookie at the same scope. A passing check also proves the old value cannot open a protected resource while the new session can.
Why replay the old cookie if its value changed after login?
Replacement alone says only that the browser received another value. A server can issue a new cookie and accidentally leave the previous session authorized, so replay is the oracle for invalidation.
Can Selenium add a cookie before visiting the site?
Open a page on the target origin first, then use Selenium's cookie command. WebDriver reports an invalid cookie domain error when code tries to set a cookie for a domain outside the current page.
Should a failed test print the session ID so developers can compare it?
Keep reusable values out of console output, screenshots, reports, and tickets. Use an ephemeral keyed fingerprint for equality evidence, and keep the raw value only in test memory until the authorized replay finishes.
Does a rotated cookie prove the whole login flow is secure?
No. Rotation addresses the fixation path, while cookie theft, authorization flaws, CSRF, XSS, timeout policy, and identity-provider sessions need their own controls and tests.
What if the application has no anonymous session cookie?
If no guest identifier exists, assert that authentication creates the documented authenticated cookie and that a caller-supplied guest value never gains protected access. Do not fail a secure design merely because there is nothing meaningful to compare before login.
RELATED GUIDES
Continue the learning route
GUIDE 01
Share a Bound Browser Between Playwright MCP Clients
A practical guide to Playwright browser bind MCP shared session, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 02
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 03
Selenium Java Cookie and Browser Storage Testing
Master Selenium Java cookie storage testing with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 04
Prevent Browser Downloads with Selenium Manager
A practical guide to Selenium Manager avoid browser download, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 05
Force Browser Downloads with Selenium Manager
A practical guide to Selenium Manager force browser download, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.