Web applications sometimes set two cookies with the same name but on different path scopes (e.g. one at / and one at /app). Deleting the cookie at / (by issuing a Set-Cookie: Max-Age=0; Path=/) will not remove the cookie on /app. This discrepancy can cause a false sense of logout: the browser may still send the remaining /app cookie on requests. We need a clear acceptance/repair playbook to verify exactly which cookie entries survive such a deletion attempt, and to ensure unrelated state remains intact.
The key decisions are fourfold: Accept the cleaned-up scope, Repair by expiring all known scopes, Hold if evidence is incomplete, and separately verify any server-side session invalidation. Importantly, client-side cleanup is distinct from actual logout or session invalidation. Clearing a browser cookie is not the same as revoking a server session. We will focus on cookie storage and request headers, not on backend session logic. We explicitly separate the cookie cleanup steps (client-only) from any claim of ending the server session, as OWASP advises that logout requires server-side invalidation.
To avoid conflating concerns, the lab only tests browser cookie behavior. For the broader secure API design context (referring to how cookies relate to auth and session management), see Refonte’s comparison on secure API design context. That blog covers authentication methods and does not alter cookie semantics. Here we isolate “delete cookie at /” and track exactly what remains.
We will walk through setting up an isolated lab, seeding two lab_session cookies (Path / and /app) plus a control lab_pref cookie, observing initial state, performing a partial expiration, and then a full expiration. Each step checks three things: (1) what the server sent (ledgered Set-Cookie fields), (2) what cookies the browser still stores (context.cookies() from Playwright), and (3) what cookies are actually sent on subsequent requests (Cookie header on /echo endpoints). Agreement among all three ensures we correctly interpret browser behavior rather than rely on a single surface.
Throughout, we will preserve an unrelated lab_pref=KEEP; Path=/ cookie as a control to ensure our actions do not wipe all cookies indiscriminately. After deleting lab_session at /, only the /app instance should remain (with lab_pref still intact). After also expiring the /app cookie, no lab_session entries should remain (but lab_pref still should). Each step is verified both in storage and on the wire. We do not claim that removing these cookies automatically logs out the user on the server. That requires a separate check. This playbook strictly covers client cleanup by path scope, not the full logout process.
Define cleanup by cookie scope rather than by name
Cookie storage is organized by (name, domain, path), not just name. In our single-origin lab, both lab_session cookies share name and domain, but differ in path scope (/ vs /app). The browser maintains them as separate entries. Thus, sending Set-Cookie: lab_session=; Max-Age=0; Path=/ will delete only the cookie whose path is exactly /. It does not automatically affect the /app one, because the cookie-path match rules require a precise match or prefix condition. In short, you must expire each named cookie at each path explicitly to clear them all.
Our four verdict categories will be:
ACCEPT: The observed cleanup matches what we intended (only the scoped entries are gone, unrelated cookies remain). No action needed.
REPAIR: A cookie still exists in scope we intended to clear. We must emit an additional Set-Cookie to remove that remaining scope.
HOLD: We lack evidence. Perhaps the browser context was not inspected properly or a cookie still appears unexpectedly; further investigation is required.
VERIFY SERVER-SIDE: Even if client-side is correct, an application may require server-side session revocation. This needs separate testing (beyond this cookie lab).
Critically, cookie cleanup here is not a claim of full logout. OWASP’s session management guide explicitly notes that logout should invalidate the session on the server side. Removing cookies is only part of it; server logic may still accept an old session ID unless it’s invalidated. We keep that distinction clear. Also note that Path is a scoping feature, not a security boundary; the browser simply won’t send a cookie outside its path scope.
Our approach is strictly this: set up an isolated loopback test, seed the cookies, attempt deletion, and check actual behavior at each stage. Any plan to delete “all cookies named lab_session” by, say, closing tabs or clearing storage, is outside the scope here. We will not use broad cookie clearing (e.g. context.clear_cookies()) as a workaround. Instead, we target exactly the intended scopes. This follows best practice: if the app is designed to set cookies at certain paths, a logout should explicitly expire each one.
Create an isolated loopback browser lab
We implement a minimal HTTP fixture server and control all client state. The server runs on 127.0.0.1 on an ephemeral HTTP port, bound to loopback only. We use CPython 3.13.5 and Playwright 1.57.0 with Chromium for the browser automation. Record the exact versions alongside the test. The server is a ThreadingHTTPServer with a custom handler, and we avoid a wildcard address. The hostname is fixed as 127.0.0.1 to ensure host-only cookies; no Domain attribute will be sent.
The server’s job is to log requests and send cookie headers exactly as specified. It has routes for:
/seed: initial response issuing three separate Set-Cookie headers (one per cookie line, as required) plus Cache-Control: no-store.
/echo, /app/echo, /application/echo: respond with JSON of the received Cookie header (for verification) plus no-store header.
/app: just return cookies too (identical to /app/echo behavior for path /app).
/expire-root: on POST, set Set-Cookie: lab_session=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax (only root scope expired).
/expire-scopes: on POST, set two Set-Cookie headers, one for Path=/ and one for Path=/app, both Max-Age=0 (explicitly expire both).
The handler code also appends each incoming request’s method, path, raw Cookie header, and all outgoing Set-Cookie fields into a shared ledger. For responses, we use Content-Type: application/json, a JSON body (like {"cookie": "<header>"}) with proper Content-Length, and Cache-Control: no-store. We do not merge multiple cookies into one header; RFC 6265 and MDN require multiple Set-Cookie lines.
Below is a simplified example fixture for the server (in Python). In practice this is run in a separate thread or process so that Playwright can drive the browser. We record the port dynamically (port = server.server_address[1]).
# server.py (Python 3.13, ThreadingHTTPServer)
import json
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
from threading import Thread
class TestHandler(BaseHTTPRequestHandler):
# Shared ledger of requests and Set-Cookie fields
server: ThreadingHTTPServer
server.ledger = []
def do_GET(self):
parsed = urlparse(self.path)
self.handle_request(method="GET", path=parsed.path)
def do_POST(self):
parsed = urlparse(self.path)
self.handle_request(method="POST", path=parsed.path)
def handle_request(self, method, path):
# Record the incoming request
cookie_hdr = self.headers.get('Cookie', '')
# Log the request in the ledger
self.server.ledger.append({
"method": method, "path": path, "cookie": cookie_hdr, "set-cookies": []
})
# Define routes
if path == "/seed":
# Issue three separate Set-Cookie headers
# lab_session=ROOT at Path=/; lab_session=APP at Path=/app
# Preservation control: lab_pref=KEEP at Path=/
cookies = [
"lab_session=ROOT; Path=/; HttpOnly; SameSite=Lax",
"lab_session=APP; Path=/app; HttpOnly; SameSite=Lax",
"lab_pref=KEEP; Path=/; SameSite=Lax"
]
for cookie in cookies:
self.send_header("Set-Cookie", cookie)
self.server.ledger[-1]["set-cookies"].append(cookie)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Type", "application/json")
self.end_headers()
# Respond with a JSON confirmation
body = json.dumps({"status": "seeded"})
self.wfile.write(body.encode())
return
# Echo endpoints: return received Cookie header
if path in ["/echo", "/app/echo", "/application/echo", "/app"]:
# ("/app" and "/app/echo" behave the same here)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Type", "application/json")
self.end_headers()
# Return JSON {"cookie": "<header>"} or empty if no cookie
body = json.dumps({"cookie": cookie_hdr})
self.wfile.write(body.encode())
return
if path == "/expire-root" and method == "POST":
# Expire the root-scope lab_session only
cookie = "lab_session=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax"
self.send_header("Set-Cookie", cookie)
self.server.ledger[-1]["set-cookies"].append(cookie)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Type", "application/json")
self.end_headers()
body = json.dumps({"status": "expired root"})
self.wfile.write(body.encode())
return
if path == "/expire-scopes" and method == "POST":
# Expire both lab_session at / and /app
cookies = [
"lab_session=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax",
"lab_session=; Max-Age=0; Path=/app; HttpOnly; SameSite=Lax"
]
for cookie in cookies:
self.send_header("Set-Cookie", cookie)
self.server.ledger[-1]["set-cookies"].append(cookie)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Type", "application/json")
self.end_headers()
body = json.dumps({"status": "expired both"})
self.wfile.write(body.encode())
return
# Default: 404 Not Found
self.send_response(404)
self.end_headers()
if name == "__main__":
# Start the server on an ephemeral port
server = ThreadingHTTPServer(('127.0.0.1', 0), TestHandler)
port = server.server_address[1]
print(f"Fixture server running on port {port}")
# Run server in background thread
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
# Run the Playwright steps here before shutting down.
pass
finally:
server.shutdown()
thread.join()In this code, server.ledger accumulates a list of dictionaries like {"method": ..., "path": ..., "cookie": "...", "set-cookies": [...]}. After each request, we can inspect this ledger to see exactly which cookies the server received and which Set-Cookie headers it sent. This separate log is one pillar of evidence, alongside Playwright’s context.cookies() and outgoing requests.
Declare the host-only and nonpartitioned limits
Because this is an isolated lab, we constrain cookie behavior to the simplest case: HTTP (no Secure flag), host-only (no Domain attribute given, so cookies only apply to 127.0.0.1), not partitioned. That means we treat the two lab_session cookies as plain older-style cookies. We omit Secure to allow HTTP; we omit Domain so each cookie applies only to 127.0.0.1 (no .-prefixed scope). We omit SameSite restrictions beyond Lax default.
This isolation avoids complexities like third-party or partitioned cookies, and makes path behavior predictable. In particular, because we use the raw loopback IP, no partitioned-cookie policies (like The CHIPS proposal) come into play. We explicitly set SameSite=Lax (or rely on default) just to match a common scenario; SameSite is not used in our logic aside from omission from headers if needed.
We also set Cache-Control: no-store on all responses to prevent any caching quirk. And note: this is purely a test fixture, not advice for production cookies. For example, in a real app Secure would usually be recommended with HttpOnly, but we omit Secure to allow http and reduce layers for this proof-of-concept. The key point is that any given browser’s actual behavior for cookies (path matching, expiration) should follow RFC 6265 and MDN regardless of these flags. Our lab does not cover Secure or partitioned cookie nuances; those are out of scope. We focus only on same-origin, same-name, different-path behavior.
Seed two same-name entries and one preservation control
With our server running, we first seed the initial state. A fresh isolated browser context is opened (browser.new_context() in Playwright) with no prior cookies. We navigate to /seed which issues our three Set-Cookie headers. After this, the browser should have two lab_session cookies plus one lab_pref cookie. Specifically:
lab_session=ROOT, Path=/, HttpOnly
lab_session=APP, Path=/app, HttpOnly
lab_pref=KEEP, Path=/ (no HttpOnly, a simple preference cookie)
According to MDN, multiple Set-Cookie headers create separate cookie entries. We record the resulting browser cookies via Playwright’s context.cookies() method. This returns a list of dicts (with name, value, path, httpOnly, etc. fields). For example, we expect to find exactly two entries where name is lab_session, one with path="/" and value ROOT, the other with path="/app" and value APP. We also expect one entry lab_pref=KEEP, path="/". We should see domain = 127.0.0.1 for all (host-only).
In code, after await page.goto(f"http://127.0.0.1:{port}/seed"), we would do something like:
cookies = await context.cookies()
print("Cookies after seed:", cookies)
assert any(
c['name']=='lab_session'
and c['value']=='ROOT'
and c['path']=='/'
for c in cookies
)
assert any(
c['name']=='lab_session'
and c['value']=='APP'
and c['path']=='/'+'/app'
for c in cookies
) # ensure trailing slash if needed
assert any(
c['name']=='lab_pref'
and c['value']=='KEEP'
and c['path']=='/'
for c in cookies
)We avoid using document.cookie here, since the lab_session cookies are HttpOnly. (Indeed, document.cookie should not list them.) Instead, we rely on context.cookies() from Playwright as our ground-truth for stored entries.
We then have our baseline. We will not re-run /seed later (that would mix cookies). The original lab_session cookies remain in place (they are persistent until expiration) throughout this context, unless explicitly expired by a response. Note that the ledger entry for /seed will have the three set-cookie values, confirming we indeed issued two lab_session cookies (RFC 6265 allows cookies with same name if path or domain differ).
Capture both session values on the app path
Next, we verify that both cookies are sent to /app (the more specific path). We make GET requests to our server at /echo, /app/echo, and /application/echo. Our goal is to see what Cookie header arrives at each endpoint. According to RFC 6265 path-match rules and the assigned paths:
A cookie with Path=/ is sent on any request under / (including /app, /application, etc.).
A cookie with Path=/app is sent on requests under /app but not on /application (since /application does not have a “/app” directory as a separate prefix). The prefix match must end at a directory boundary, per RFC 6265. Note that /application does not count as a subpath of /app (it does not have “/app/” in it). MDN similarly notes that path matching is a prefix with a slash boundary, not just string containment.
Thus we expect:
On /echo (root-level), only the lab_session=ROOT cookie is sent (since Path=/app cookie’s path /app does not match / aside from root).
On /app/echo (path under /app), both cookies are sent: the / cookie (prefix of /app/echo) and the /app cookie (exact match for prefix with slash).
On /application/echo, only the / cookie is sent (the /app cookie does not apply, because /application is not under /app in path-match terms).
We also check /app itself (a GET or POST) which should behave same as /app/echo. /application/echo acts as a negative control showing a cookie with a path /application did not exist, confirming the prefix rules. This tests the slash boundary explicitly. The /application endpoint ensures we don't rely on naive substring matching; it exercises the distinction in the RFC definition.
In our Playwright script, we can use context.request.get (an API testing helper introduced in Playwright v1.16) which automatically sends the context’s cookies with the request. For each endpoint:
resp_root = await context.request.get(f"http://127.0.0.1:{port}/echo")
cookie_root = (await resp_root.json())["cookie"]
resp_app = await context.request.get(f"http://127.0.0.1:{port}/app/echo")
cookie_app = (await resp_app.json())["cookie"]
resp_app2 = await context.request.get(f"http://127.0.0.1:{port}/app")
cookie_app2 = (await resp_app2.json())["cookie"]
resp_other = await context.request.get(f"http://127.0.0.1:{port}/application/echo")
cookie_other = (await resp_other.json())["cookie"]We preserve the raw header string, which will look like "lab_session=ROOT; lab_session=APP; lab_pref=KEEP" or similar (depending on ordering). We split by ; to parse name/value pairs, preserving duplicates. It’s crucial not to transform into a dict (which would lose duplicate names); instead compare multisets of values.
The table summarizes the expected outcomes, not results from a completed browser run. Browser execution was blocked in the described environment. All rows should still include lab_pref=KEEP, but only lab_session is listed here for clarity.
Stage | /echo (Cookie header) | /app/echo (Cookie header) | /application/echo (Cookie header) | Browser cookies stored (name=lab_session) |
After seed | ROOT | ROOT and APP | ROOT | Paths / and /app |
After expire-root | (none) | APP | (none) | Path /app only |
After expire-scopes | (none) | (none) | (none) | (none) |
After seed: /echo should receive only the / cookie (ROOT), /app/echo should receive both ROOT and APP, and /application/echo should receive ROOT. The browser should store both lab_session entries at / and /app.
After expire-root: we have deleted the cookie at / but not at /app. /echo and /application/echo should now get no lab_session values, because the root cookie is gone. /app/echo still should get the remaining /app cookie (APP). Browser should now have only the /app cookie (path /app).
After expire-scopes: we expire both. No lab_session should appear anywhere, neither in storage nor sent to any endpoint. The browser’s context.cookies() should show no entries for lab_session at this point.
We will later verify in detail, but this table guides our expectations. We also always preserve lab_pref=KEEP at path /; none of our actions target that, so it should appear in all request headers and in storage unchanged, acting as a negative control for over-broad deletion.
Test the slash boundary rather than a loose prefix
The use of /application/echo illustrates that the path-match is not a simple substring search. The path /application/echo does not match the Path=/app cookie, because the next character after /app in /application is 'l', not a slash. RFC 6265 explicitly requires either exact equality or that the next character in the request path after the cookie path be “/”. MDN also notes “subdirectories match as well” only when the defined path is a prefix that ends at a directory boundary. Hence lab_session=APP (Path /app) should not be sent to /application. If we had mistakenly matched on substring, we’d see APP there; observing it absent confirms correct behavior.
We log these cookie lists and check they match the multisets {ROOT}, {ROOT, APP}, and {ROOT}, then {} and {APP} as expected. Any discrepancy means a browser is doing something different. (We do not expect any since browsers follow RFC 6265 path-match.)
Expire only the root-scope entry
Now we simulate an incomplete logout attempt: send a request to /expire-root, causing the server to respond with Set-Cookie: lab_session=; Max-Age=0; Path=/. This expires the cookie at / only. After this response, we do not close the context or clear anything manually; we simply check the new state.
In the server ledger, /expire-root should have one set-cookies entry, confirming we only sent the root expiration. (An endpoint that omitted Path could default to the request path, but our code used Path=/ explicitly to avoid ambiguity.)
Now observe the three perspectives:
Server’s Set-Cookie log: it should record exactly one Set-Cookie with Path=/ and none with Path=/app.
Browser storage (context.cookies()): It should now have only one lab_session entry, namely the one with path='/app' and value='APP'. The ROOT cookie should no longer be present, as per MDN’s deletion rule (matched exactly by name+path). We must assert exactly one entry with name lab_session remains. (Of course lab_pref remains untouched too.)
Next requests: On /app/echo, the APP cookie should be sent; on /echo or /application/echo, there should be none. We repeat the requests as above. The request to /echo and /application/echo should show no lab_session values in the echoed header JSON. The /app/echo request should still echo “APP”.
For example, our Playwright code may do:
await page.goto(f"http://127.0.0.1:{port}/expire-root", method="POST")
cookies_after = await context.cookies()
assert all(c['name']!='lab_session' or c['path']=='/app' for c in cookies_after)
# Optionally assert the count is 1 and value is 'APP'.
resp_app = await context.request.get(f"http://127.0.0.1:{port}/app/echo")
assert (await resp_app.json())["cookie"] == "lab_session=APP; lab_pref=KEEP"
resp_root = await context.request.get(f"http://127.0.0.1:{port}/echo")
assert (await resp_root.json())["cookie"] == "lab_pref=KEEP" # no lab_session
resp_other = await context.request.get(f"http://127.0.0.1:{port}/application/echo")
assert (await resp_other.json())["cookie"] == "lab_pref=KEEP"(Note: lab_pref=KEEP should still appear in cookies on all paths, since it has Path=/ and was never expired. We include it in assertions or logs to ensure we didn’t accidentally delete it.)
If the root cookie still appeared anywhere after this stage, we would have to REPAIR by sending the missing expiration, but it should not. This is our negative control check of the “expired one cookie” scenario.
The expected result is that expiring only the / cookie leaves the /app cookie intact. The two entries are scoped separately by path.
Show why document.cookie cannot prove cleanup
It might be tempting to check document.cookie in the page for presence or absence of lab_session. However, both lab_session cookies were set HttpOnly, meaning JavaScript cannot see them at all. Even before expiration, document.cookie would be empty (or only show lab_pref if HttpOnly were not set). After expiring the root cookie, document.cookie remains unable to show lab_session=APP, giving no visibility of what’s really happening. In other words, absence from document.cookie doesn’t mean absence in storage or on requests. It only means scripts aren’t allowed to read it. MDN explicitly warns that cookies needed for sessions should be HttpOnly specifically so they aren’t exposed to JS.
For example, right after /seed, document.cookie would show nothing for lab_session. Even after expiring root, document.cookie still won’t list APP. But we know APP is being sent on /app/echo. Relying solely on document.cookie would mislead us. We therefore use context.cookies() (Playwright API) to inspect HttpOnly cookies and we check server-echoed Cookie headers. These give the full picture.
Keep Set-Cookie response filtering separate from server behavior
One more subtlety: if we tried to use fetch() or XMLHttpRequest in the page to inspect the Set-Cookie header that the server sent, we would find it missing, due to browser security. The Set-Cookie header is a forbidden response header for frontend scripts. For example, response.headers.get("set-cookie") in JavaScript would return null, even though the cookie was actually set. This is just a protective feature (specified in the fetch spec) and not a bug. We must not confuse that “missing header” with “server didn’t send it.” The only reliable way to see what was sent is the server’s log (our ledger).
In our Playwright automation, we will not rely on examining Set-Cookie in the browser. Instead, we use server.ledger to see the exact headers sent, and trust that Playwright has already applied them into context.cookies(). This is separate evidence from the browser’s perspective.
Repair the declared expiration scopes
The expected behavior is that deleting the root cookie alone removes only the root cookie. To clear the declared session-cookie scopes, we must explicitly expire each path-scoped cookie. The repair step is to issue a response that also expires the /app cookie. That is what our /expire-scopes route does: it sends two Set-Cookie lines, one with Path=/ and one with Path=/app, both with Max-Age=0.
In code, after the incomplete cleanup, we do:
await page.goto(f"http://127.0.0.1:{port}/expire-scopes", method="POST")Then we check again. The server’s ledger should show two separate Set-Cookie fields were sent this time (one for each path). The browser’s cookie store should now show zero entries for lab_session. A call to context.cookies() should return an empty list or at least no lab_session.
Important: We must not rely on the browser inferring the same default path. Each cookie path is explicit, so no ambiguity. If we had sent a Set-Cookie: lab_session=; Max-Age=0 without a Path, the browser would use the request path as default (i.e. /expire-scopes default path /expire-scopes, likely not matching either intended cookie), which would fail. That’s why our test explicitly includes Path=/app. In practice, ensure your server’s code expiration path parameter exactly matches the cookie’s original path.
After this repair request, we perform the same checks as before:
Server ledger: should list two set-cookies for lab_session, at / and /app.
Browser cookies: context.cookies() should list no lab_session entries. The list may still contain lab_pref=KEEP; we check that it remains. Crucially, the number of lab_session cookies must be exactly zero, not just “fewer” or “not visible to JS”.
Requests: calling /app/echo or /echo should now echo no lab_session values. Only lab_pref should appear if at all.
For example:
cookies_after_repair = await context.cookies()
assert not any(c['name']=='lab_session' for c in cookies_after_repair)
resp_final = await context.request.get(f"http://127.0.0.1:{port}/app/echo")
assert (await resp_final.json())["cookie"] == "lab_pref=KEEP"Absence of lab_session in all these traces is the acceptance condition for full cleanup of the declared cookie scopes.
If any lab_session entry were found, we would categorize it as HOLD and investigate the difference. But RFC 6265 and MDN assure us that matching name+path is the exact key, so with both paths expired, there should be none left.
Verify storage absence and request absence together
With repair sent, we must double-check there are no surprises. We do final requests to /echo, /app/echo, and even /app itself. All should see no lab_session. Additionally, we require that the number of cookie entries reported by context.cookies() is exactly zero for lab_session. It is not enough to say “fewer cookies”; we need explicit absence. We also check that lab_pref=KEEP is still present, to confirm we haven’t cleared the profile.
We include /app (the directory, not /app/echo) as one more control: it should also receive no lab_session. This is redundant logically but confirms boundary behavior wasn’t weird.
At this point, all three observation methods should agree: the server log shows no lab_session set-cookie sent at this stage, browser storage has no lab_session entries (and lab_pref intact), and requests carry no lab_session cookie.
Here is a summary of what we expect just before cleaning up:
Browser context has only lab_pref=KEEP in cookies.
/echo, /app/echo, /application/echo requests all send a header like lab_pref=KEEP (or nothing if that is omitted by domain/path rules; but here it has Path=/, so it is sent everywhere).
None of these headers should include any lab_session.
If any lab_session were still present, that would violate the contract of the expiration scopes and trigger further repair. We do not use context.clear_cookies() or close the context to “fix” anything, because that would muddy whether our expiration logic was correct. We explicitly handle each path we know about.
Preserve the unrelated preference cookie
Throughout all tests, we did not delete lab_pref=KEEP. This cookie acts as a negative control to ensure our deletion logic is targeted. At every stage (/seed, after /expire-root, after /expire-scopes), we check that lab_pref is still there. That means our code did not inadvertently clear all cookies. We did not rely on Clear-Site-Data or similar broad commands (which are discouraged, per OWASP’s note that web apps should explicitly clear sensitive cookies and use no-store to avoid caching).
The test code should include assertions like:
assert any(
c['name']=='lab_pref' and c['value']=='KEEP'
for c in await context.cookies()
)at each stage. If lab_pref were missing, we would have to investigate an error in our fixture or test, not the cookie logic.
Record browser evidence without changing the result
When implementing these checks in automation, it is vital that we do not inadvertently modify the state. We only want to inspect after each server response. We avoid any API calls like context.clear_cookies(), context.add_cookies(), or closing/reopening the context as a cleanup step, because that would hide or artificially fix an issue. Our Playwright code must sequentially perform each action (seed, expire root, expire scopes) and then inspect state via context.cookies() and context.request. We do use await to ensure one step finishes before the next begins.
All assertions should be deterministic. For example, after /expire-root, we explicitly check that exactly one lab_session remains (the /app one), not just “there is at least one”. We might assert len([c for c in cookies if c['name']=='lab_session']) == 1 and so on. The server ledger can also be inspected after the script runs (in the try/finally block) to ensure it received and sent exactly the cookies we intended.
If any assertion fails, that indicates a discrepancy. We do not catch or retry inside the test; a failure is final. (Of course, in a real-world flaky environment we might retry, but here we treat it as a strict lab. A failure means the evidence was not as expected, leading to a HOLD or bug investigation. We avoid masking it with sleeps or additional requests.)
Log the browser and driver versions at the start of the test, including browser.version, so that the evidence identifies the browser in use. The described environment used Playwright 1.57.0 and Chromium. Browser execution was blocked, so the outcomes in this playbook remain expected rather than measured. A completed run is required before accepting the cleanup result.
Name the server-session question that remains open
Even after successfully expiring both cookies on the client, one must ask: is the server treating the user as logged out? That question is outside this cookie-scope lab. We have only proven that the browser no longer holds the session cookies. We have not tested whether the server’s session store still considers those cookies valid. A fully robust logout procedure would have an application-level check (for example, seeing that the now-absent session ID is no longer accepted or triggers a 401). Without such a test, we cannot claim “the user is fully logged out.”
Indeed, OWASP’s guidance is clear: a logout or timeout should cause server-side invalidation of the session. Some applications rely solely on the cookie going away (and expect it not to be sent again), but a determined attacker or bug could reuse a cookie if the server still trusts it. Therefore, even after our client cookie deletion tests, a QA team should perform a server-side check: e.g. try making an authenticated API call using the old cookie and expect rejection. That is explicitly separate from this article’s scope. Here we just note that deleting cookies does not guarantee termination of the server session.
The linked Refonte blog on identity lifecycle beyond a browser cookie covers broader IAM lifecycle topics and echoes the idea that client storage is not the whole story. Thus, after client cleanup, typically the server should also remove or expire the session on its end. The experiment here does not prove that; it only proves the browser won’t send those cookies anymore.
Handle blocked or unavailable browser execution
The reported attempt using Playwright 1.57.0, CPython 3.13.5 and Chromium was blocked by net::ERR_BLOCKED_BY_ADMINISTRATOR while navigating to the loopback /seed endpoint. Environment restrictions prevented the automated browser steps from completing. No actual run results were captured.
The values in the matrix are expected outcomes based on documentation and the declared cookie scopes. They are not measured browser results. A blocked run cannot establish that the cleanup passed.
Once browser execution is available, run the described checks and record the results. The expected outcomes follow RFC 6265 and MDN’s cookie documentation, but documentation does not replace runtime evidence. Until a run is completed, hold the final cleanup judgment.
Apply the cleanup decision matrix
Use the observed evidence to choose among the Accept, Repair and Hold outcomes. The expected matrix defines the criteria; it is not itself evidence of a passing run:
Accept the scope cleanup: Accept only when the evidence shows that exactly the intended cookies were removed and unrelated cookies remain. For root-only expiration, the expected result is that the root-scoped cookie is gone while /app remains. That matches the declared scope of the operation, but clearing both session-cookie scopes still requires repair. After full repair, accept only when no lab_session entries remain in browser storage or on the checked requests. Precise counts and values matter more than a general statement that a cookie disappeared.
Repair the expiration scopes: After the first POST to /expire-root, the expected surviving /app-scoped cookie means that cleanup of both scopes is incomplete. Issue the additional expiration through /expire-scopes, then repeat the storage and request checks. The repair is not accepted until its result is observed.
Hold if unproven: Hold when absence cannot be confirmed or evidence conflicts. If context.cookies() is inaccessible or the server log is incomplete, document the uncertainty and pause before claiming cleanup success. Because the described browser execution was blocked, the final judgment remains HOLD pending a completed run.
Verify server-side separately: After demonstrating client cookies are gone, one might claim “user logged out.” We label that a separate check. If we tried to say “we have logged out,” it would be incorrect without testing the server. So we hold that claim and note it explicitly.
The expected decision outcomes are:
Only the /app cookie should remain after root-only expiration. Accept that operation’s declared scope, but repair the remaining scope before accepting full cleanup.
No lab_session entries should remain after both expirations. Final ACCEPT requires that absence to be observed.
An unchanged lab_pref throughout is the preservation control for targeted cleanup.
The expected outcomes follow the documented cookie rules. If the /app cookie disappeared after expiring only /, that would require investigation and repair. A blocked run provides no evidence that such a discrepancy did or did not occur.
In summary, the decision path is:
Observe that cookies are identified by both name and scope.
Attempt deletion at the root path. If /app survives, cleanup of both scopes is incomplete and repair is needed.
Issue the explicit expiration for /app. Accept full cleanup only after observing that no lab_session entries remain.
Verify that lab_pref remains throughout, confirming that unrelated state is unaffected.
Do not claim logout success from cookie cleanup alone. Server-side session invalidation remains a separate verification requirement.
For QA and development, the key point is that a logout operation that expires only the root cookie does not clear both declared scopes. Add the missing expiration scope, then verify the result in browser storage and request headers before accepting client-side cleanup.
Assign ownership across frontend, API and QA
Multiple teams play a role:
Frontend Developers are responsible for how cookies are set and expired. They define the cookie names and paths (for example, Set-Cookie: lab_session=... Path=/app). They should ensure that logout logic issues a Set-Cookie (Max-Age=0) for each cookie it created, with matching path. If a scope is missed, it’s a frontend bug. They also write the tests (or guide QA) using Playwright or similar to confirm that cookies are actually removed.
API/Maintenance team (backend) owns the server’s session management. They decide when a session is invalidated and what cookies are used to track it. Even after frontend deletes cookies, the backend must clear its session store or token if they truly want to log the user out. This experiment did not cover that; it’s their responsibility to test it separately (e.g., attempt an API call with an expired session ID and expect a rejection). If cookies are HttpOnly, as in our lab, the backend already expects them and will not see them via JS.
QA/Automation engineers should verify the full workflow. Using a controlled fixture, they implement the kind of Playwright script described here. They ensure the environment is repeatable (fresh context, stable server). They check the cookie jar via browserContext.cookies() and monitor the server’s receipt of cookies. This falls under “automation layers for browser validation” where QA does exactly these end-to-end checks. The automation layer ensures deterministic sequencing (no stray timers or unpredictable loads). If any step fails, QA must file a bug.
Refonte’s API development guide discusses API ownership: frontend sets cookie details, backend handles token validity, QA automates the checks. Each must own a part of the result. For example, if after deletion the backend still accepts requests (server session still valid), that’s a separate backend issue; QA should log it as a bug for the backend team, not blame the frontend removal. Conversely, if the backend refuses even though cookies remain, that’s a backend misconfiguration or overzealous invalidation. Clear ownership here prevents finger-pointing.
In practice, the QA automation script could even be part of a continuous integration test: spin up the server in test mode, run Playwright as above, and assert the exact cookies. Any mismatch can quickly highlight “we forgot the Path=/app expiration”. Having it automated avoids subtle human error in manually interpreting dev tools.
Build broader frontend engineering foundations
This targeted experiment fits into a larger context of reliable client-side state handling. Cookies, paths, SameSite, and storage mechanisms are fundamental topics that any frontend dev should master. To systematically understand browser storage and avoid pitfalls, consider strengthening your foundation through Refonte Learning’s FrontEnd Developer Program. That program covers HTML, CSS, JavaScript (including React/Redux), API integration, Git, and practical projects, essentially the skills needed to build and troubleshoot web apps, including cookie-based flows. Learn more on the FrontEnd Developer Program page to deepen your expertise in web development best practices (and avoid the very cookie scope bugs we investigated here).
