NEWScrapingAnt MCP for Claude Code, Cursor & Windsurf — try it free →
Skip to main content

Playwright Cookies: Save State and Share Sessions with API Requests

· 12 min read
Oleg Kulyk
Co-Founder @ ScrapingAnt

Playwright Cookies: Save State and Share Sessions with API Requests

Updated 2026-09-27

Replaced mixed sync/async recipes, manual Set-Cookie parsing and untested state assumptions with runnable Chromium and Firefox experiments. The new examples test context restoration, browser/API cookie sharing, missing local storage and duplicate requests against extracted catalog records. The original URL, banner and publication date are preserved.

To set cookies with Playwright Python, call context.add_cookies() with a list of cookie dictionaries. Supply url, or a suitable domain and path, and set the cookies before navigating when the first request needs them. Use context.storage_state() when the workflow also depends on supported browser storage beyond cookies.

Restoring the cookie does not necessarily restore the dataset. In our catalog, cookies-only restoration kept member pricing but selected USD instead of EUR. A browser-associated API request made the same mistake when it omitted a region parameter that the page normally reads from local storage.

This guide shows the working state handoff and the failures around it. The fixture is self-authored and synthetic; it demonstrates specific mechanisms, not the probability that a real website will accept a restored login.

The examples below use Playwright's synchronous Python API consistently. Cookie operations belong to the browser context:

TaskAPIWhat it means for extraction
Add cookiescontext.add_cookies([...])The context can receive URL-scoped cookies before a page navigates
Inspect cookiescontext.cookies()Inspect the context jar; avoid printing real values
Select cookies for a targetcontext.cookies(target_url)Start with the cookies applicable to that URL
Clear cookiescontext.clear_cookies()Recheck the next page's session and data
Capture statecontext.storage_state()Save the supported state your workflow actually needs
Restore in a fresh contextbrowser.new_context(storage_state=state)The restored context is a separate context, not a live link to the original
Make API calls with browser cookiescontext.requestResponses can also change the shared cookie jar
Make isolated API callsplaywright.request.new_context()Transfer required state explicitly when needed

The BrowserContext reference defines these operations. Cookie values still need to mean something to the target server. The made-up member value in this packet works only because our fixture recognizes it; inventing a session cookie does not create a real authenticated session.

Run the complete state restoration example​

Download or check out the tested packet, then change into examples/playwright-set-cookies. With Python 3.12 installed:

python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt
python -m playwright install chromium
python quickstart.py
./run.sh

On a Linux machine missing browser system dependencies, use Playwright's install --with-deps chromium setup. The packet's README documents the optional Firefox run. The recorded environment was Python 3.12.10, Playwright 1.63.0, Chromium 153.0.8010.12 and Firefox 155.0 on macOS 26.6.2 arm64. These are tested versions, not an assertion that they will remain current.

Here is the complete quickstart.py. The fixture, extraction functions, region adapter and record oracle are supplied in the same directory.

"""Complete, no-secret example: save, restore, extract, and explicitly query API."""
import argparse
import json
from pathlib import Path
from tempfile import TemporaryDirectory

from playwright.sync_api import sync_playwright

from browser_matrix import api_records, browser_records, environment
from catalog_fixture import serve_catalog
from state import region_params, score_records


def main():
with serve_catalog() as fixture, TemporaryDirectory(prefix='synthetic-playwright-state-') as temporary:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
try:
source = browser.new_context()
# Only this synthetic fixture recognizes this made-up session value.
source.add_cookies([{'name': 'demo_session', 'value': 'member-v1',
'url': fixture.origin, 'httpOnly': True}])
page = source.new_page()
page.goto(fixture.origin + '/catalog')
page.evaluate("localStorage.setItem('region', 'EU')")
state_file = Path(temporary) / 'state.json'
state = source.storage_state(path=state_file)
source.close()

context = browser.new_context(storage_state=state_file)
try:
browser_result = browser_records(context.new_page(), fixture.origin)
api_result = api_records(context.request, fixture.origin, region_params(state, fixture.origin))
result = {
'environment': environment(browser),
'browser': {**browser_result, **score_records(browser_result['records'])},
'api': {**api_result, **score_records(api_result['records'])},
'saved_cookie_names': [cookie['name'] for cookie in state['cookies']],
'saved_region': region_params(state, fixture.origin)['region'],
}
assert result['browser']['exact_match'] and result['api']['exact_match']
finally:
context.close()
finally:
browser.close()
result['temporary_state_file_removed'] = not state_file.exists()
return result


if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--output', type=Path)
args = parser.parse_args()
data = json.dumps(main(), indent=2) + '\n'
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(data)
print(data, end='')

The cookie is inserted before the first navigation. That first page establishes the origin so the example can set the fixture's local-storage region to EU. The script saves state in a temporary directory, closes the source context, and creates a fresh context from the saved file.

It then extracts the catalog twice: once from the rendered browser table and once from the JSON API. The latter call explicitly supplies the region derived from the captured state. Selected fields from the real output:

{
"browser": {
"target_status": 200,
"matching_records": 4,
"exact_match": true
},
"api": {
"target_status": 200,
"matching_records": 4,
"exact_match": true
},
"saved_region": "EU",
"temporary_state_file_removed": true
}

The full capture contains four matching SKU/currency/price records from each path. The expected member/EUR prices are the synthetic strings 8.10, 16.20, 24.30 and 32.40. The oracle defines those records independently of the fixture's response-building code, so duplicate copies of one product cannot stand in for missing products.

The example uses HTTP only on loopback and contains no real login. The temporary state file is removed. A saved state file from a real site can contain credentials: keep it private and outside Git, screenshots and logs. It is not an encrypted credential store.

Cookies and local storage solve different parts of this task​

Our fixture's page reads a region value from local storage and sends it as a query parameter to /api/catalog. The session cookie selects member versus retail prices. The query parameter selects the currency. That application design is explicit in the fixture source; it is not a claim that every catalog works this way.

We ran 12 extraction cases in three rounds in each browser: 72 extraction observations, plus 36 diagnostics, for 108 checks. Every extraction returned HTTP 200 and four rows. Each case below therefore has six observations, all agreeing on the reported outcome.

State or request pathCorrect member/EUR recordsWhat happened
Fresh browser context0/4Retail USD
Cookie before navigation plus explicit region initialization4/4Member EUR
Server-seeded browser state4/4Member EUR
Full storage_state restoration4/4Member EUR
Restore cookies only0/4Member session survived; local-storage region did not, so USD
Shared context.request plus explicit region4/4Member EUR
Shared API request without region0/4Cookies shared; region omitted, so member USD
Isolated API context with region but no state handoff0/4EUR selected; session absent, so retail
Isolated API context with state handoff and explicit region4/4Member EUR
Another fresh browser context0/4Independent retail USD state
Shared API response changes the session cookie0/4Later browser extraction becomes retail EUR
Session cookie scoped to the wrong path0/4Region preserved; session not sent, so retail EUR

See the report, Chromium capture and Firefox capture. A check passes when the experiment observes the specified outcome, including an intentional wrong-data result. The 108 checks do not mean all 72 extractions returned the intended dataset.

For browser restoration, storage_state retained the cookie and the relevant local-storage entry in this fixture. A cookies-only export could not carry that entry. For API calls, sharing cookies was only half the task: the HTTP client did not execute the page's JavaScript to turn local storage into a query parameter. The packet's region_params() adapter performs that application-specific translation and rejects missing, ambiguous or wrong-origin input.

Use your target's actual data flow to identify the equivalent state: region, language, account role, selected store or another parameter. Check the records after restoring it. Do not copy this fixture's EU parameter into an unrelated API and assume it has the same meaning.

Choose shared or isolated API state deliberately​

The APIRequestContext documentation describes the shared jar behind context.request and the separate jar created by playwright.request.new_context().

That sharing works in both directions. In our mutation case, a call through context.request received a new guest session cookie. The next browser request used it and returned retail prices. A different browser context retained its own state. This matters if an API setup, logout or account-switch endpoint returns Set-Cookie while a page is still using the context.

Use the associated request context when that shared state is intentional. Use an isolated API context when its cookie changes should stay separate, and pass the state and request parameters it actually needs. Neither choice turns a storage snapshot into a universal login-transfer format.

The HttpOnly diagnostic also matters for exports: the context cookie API saw the demo session, while document.cookie did not. The packet therefore uses the context API rather than a JavaScript-only cookie export.

Avoid sending an intercepted request twice​

The previous article used route.fetch() and then route.continue_(). We tested that pattern against a server-side request counter, separately from the product-record matrix:

Handling one intercepted navigationRequests reaching the fixture
No interception1
route.fetch() then route.continue_()2
route.fetch() then route.fulfill(response=response)1

The counts agreed in all six browser/round observations per pattern. The Route reference explains the two operations: fetching obtains a response, while continuing sends the intercepted request onward. Fulfilling with the fetched response completes that route without a second normal send in this test.

For ordinary server-issued cookies, let normal response handling populate the context jar. Intercept only when you need to inspect or change traffic. If you do fetch inside a handler, decide how that response will fulfill the route; do not parse a complete Set-Cookie header by splitting on the first equals sign. Our counter test establishes an extra request to the fixture, not a production latency or billing estimate.

If ScrapingAnt handles page retrieval in your extraction pipeline, carry over only the cookie values appropriate to that target. ScrapingAnt accepts cookies as name/value pairs through the cookies parameter, with multiple pairs separated by semicolons. ScrapingAnt's extended endpoint returns received cookies in its cookies field, so you can pass the relevant pairs into a later request.

The receive/replay behavior has a separate measured result: the September 27 cookie-echo capture published with the Selenium packet. Four calls per datacenter mode established baseline, cookie receipt, a new request without replay, and explicit replay. Both browser modes received and resent the expected synthetic pairs. The eight calls used 44 credits, with all receipts captured.

The product rules for those settings are: A request without a browser through a datacenter proxy costs 1 API credit. A request with JavaScript rendering through a datacenter proxy costs 10 API credits. No new paid calls were needed for this Playwright refresh, and this local catalog was not fetched through ScrapingAnt.

A Playwright storage_state file is not the API's cookie parameter. Select target-appropriate pairs and handle other required state separately. The name/value string does not preserve cookie scope attributes or automatically transfer local storage, and an echo test does not establish real-session portability or renewal. ScrapingAnt is not needed for the local fixture or a Playwright browser workflow that already retrieves your data. For remote retrieval with explicit cookie chaining, start with the custom-cookie guide.

Reproduce the checks and keep the limits visible​

./run.sh runs the local checks, quickstart and three Chromium rounds. ./run.sh --all-browsers also runs Firefox after you install that bundled browser. Reruns write to ignored run_output/; the README explains how to inspect the preserved captures and rebuild the summary. The default workflow makes no external target or product API calls.

These tests cover one deterministic application on two browser engines. They do not test cross-site SameSite behavior, partitioned cookies, real authentication renewal, SSO, MFA or whether a site accepts a session on a different network. We exercised cookies and local storage, not every optional store supported by current storage_state APIs. Session storage needs separate handling; see Playwright's authentication guide.

For more on the storage part, see Playwright local storage. For the equivalent cookie work in other clients, see Selenium cookies and Python Requests cookies.

Examples tested on 2026-09-27 with Python 3.12.10, Playwright 1.63.0, Chromium 153.0.8010.12 and Firefox 155.0. Code and captures: pinned evidence packet.

This article was drafted with AI assistance from a tested evidence packet. The named author is responsible for the code, measurements and corrections.

Forget about getting blocked while scraping the Web

Try out ScrapingAnt Web Scraping API with thousands of proxy servers and an entire headless Chrome cluster