[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #57 · 2026-10-17 · Example · Easy · for [testing](https://cdpfleet.com/cross-browser-testing), [web scraping](https://cdpfleet.com/web-scraping)

# Inspect and edit the cookie jar of a remote browser — and why WebKit refuses cookies on httpbin.org

Read the cookies a page would send, change a value and an expiry, delete one by name and carry the login into a new session as about 150 bytes of JSON. Then set cookies three ways on three hosts in Chromium, Firefox and WebKit: WebKit refuses every cookie on httpbin.org and postman-echo.com, which are on the Public Suffix List.

[Chromium](https://cdpfleet.com/docs/browsers/chromium) [Firefox](https://cdpfleet.com/docs/browsers/firefox) [WebKit](https://cdpfleet.com/docs/browsers/webkit)

Run on production on 2026-10-17: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

## The problem

A scraper often needs to look inside the cookie jar: is the login cookie there, when does it expire, what did the consent banner set? Sometimes it has to change one — extend a session, switch a preference — or move a login from one run to the next without keeping a whole browser profile. Playwright's context cookie API does all of this over the remote connection. And when a cookie that works in Chromium silently doesn't stick in WebKit, it helps to know why.

## What we used, and why

| What | Why |
| --- | --- |
| `context.cookies(url)` | The cookies that would be sent to that URL — domain, path and secure flag applied. Without a URL it returns every cookie in the context. |
| `context.addCookies([...])` | Adds cookies, or replaces one with the same name, domain and path: the way to edit a value or an expiry. `expires: -1` is a session cookie. |
| `context.clearCookies({ name })` | Deletes only the matching cookies (`name`, `domain` and `path` filters, Playwright 1.43+); without a filter it empties the jar. |
| `www.httpbin.org/cookies` | Echoes the cookies the server received, so every change is checked from the site's side too. |
| `proxy` | Your own proxy; a few KB of traffic. |

## How it works

1.  Chromium: the site sets `session_token` and `theme` (session cookies); a cookie for example.com is added too. Read the whole jar, then only the site's cookies.
2.  Edit: give `session_token` an expiry a week out and change `theme` to `light` by adding them again; the site echoes what it received.
3.  Delete `theme` by name, save the site's cookies as JSON and close the session.
4.  Launch a new session, add the saved cookies to an empty context and ask the site what it sees.
5.  In Chromium, Firefox and WebKit, set a cookie three ways — `addCookies`, a server `Set-Cookie` and `document.cookie` — on httpbin.org, www.httpbin.org and postman-echo.com, and read back what was kept.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/57-inspect-and-edit-the-cookie-jar), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)
-   `PROXY_URL` — your proxy, e.g. `http://user:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
import { chromium, firefox, webkit } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
const SITE = 'https://www.httpbin.org';

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status !== 503 || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
    await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
  }
}

async function open(engine) {
  const session = await launch(engine, { proxy: PROXY, headless: true, record: false });
  const type = { chromium, firefox, webkit }[engine];
  return type.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
}

// httpbin answers 502/503 now and then: retry a page that isn't JSON.
async function json(page, url) {
  for (let attempt = 1; ; attempt++) {
    await page.goto(url, { timeout: 45000 });
    try { return JSON.parse(await page.textContent('body')); } catch (err) {
      if (attempt === 4) throw err;
      await new Promise((r) => setTimeout(r, 3000));
    }
  }
}
const names = (cookies) => cookies.map((c) => c.name).sort();

// Part 1: inspect, edit, clear and carry the jar (Chromium).
const jar = {};
let saved;
{
  const browser = await open('chromium');
  try {
    const context = await browser.newContext();
    const page = await context.newPage();
    // The site sets two cookies (no expiry: session cookies); another site sets one more.
    await json(page, `${SITE}/cookies/set?session_token=abc123&theme=dark`);
    await context.addCookies([{ name: 'other_site', value: '1', url: 'https://example.com' }]);
    jar.all_cookies = names(await context.cookies());
    const mine = await context.cookies(SITE); // only the cookies that would be sent to this URL
    jar.for_site = names(mine);
    jar.session_cookie_expires = mine.find((c) => c.name === 'session_token').expires; // -1 = session cookie

    // Edit: addCookies with the same name, domain and path replaces the cookie.
    const weekFromNow = Math.floor(Date.now() / 1000) + 7 * 86400;
    await context.addCookies([
      { ...mine.find((c) => c.name === 'session_token'), expires: weekFromNow },
      { ...mine.find((c) => c.name === 'theme'), value: 'light' },
    ]);
    const edited = await context.cookies(SITE);
    jar.token_expires_in_days = Math.round((edited.find((c) => c.name === 'session_token').expires - Date.now() / 1000) / 86400);
    jar.server_sees_after_edit = (await json(page, `${SITE}/cookies`)).cookies;

    // Clear one cookie by name (other filters: domain, path).
    await context.clearCookies({ name: 'theme' });
    jar.after_clear_by_name = names(await context.cookies());

    // Carry the login as plain JSON: just the site's cookies.
    saved = JSON.stringify(await context.cookies(SITE));
    jar.saved_json_bytes = saved.length;
  } finally {
    await browser.close();
  }
}
{
  const browser = await open('chromium'); // a new session on another browser
  try {
    const context = await browser.newContext();
    jar.new_session_before = names(await context.cookies());
    await context.addCookies(JSON.parse(saved));
    const page = await context.newPage();
    jar.new_session_server_sees = (await json(page, `${SITE}/cookies`)).cookies;
  } finally {
    await browser.close();
  }
}

// Part 2: the same three ways to set a cookie, on three hosts, in three engines.
// httpbin.org and postman-echo.com are on the Public Suffix List; www.httpbin.org isn't.
const HOSTS = ['httpbin.org', 'www.httpbin.org', 'postman-echo.com'];
const psl = [];
for (const engine of ['chromium', 'firefox', 'webkit']) {
  const browser = await open(engine);
  try {
    for (const host of HOSTS) {
      const context = await browser.newContext();
      const page = await context.newPage();
      const url = `https://${host}`;
      await context.addCookies([{ name: 'via_add_cookies', value: '1', url }]);
      await json(page, `${url}/cookies/set?via_set_cookie=1`); // Set-Cookie, then a redirect to /cookies
      await page.evaluate(() => { document.cookie = 'via_document_cookie=1; path=/; max-age=3600'; });
      const kept = names(await context.cookies(url));
      psl.push({
        engine,
        host,
        add_cookies: kept.includes('via_add_cookies'),
        set_cookie: kept.includes('via_set_cookie'),
        document_cookie: kept.includes('via_document_cookie'),
        server_sees: Object.keys((await json(page, `${url}/cookies`)).cookies).sort(),
      });
      await context.close();
    }
  } finally {
    await browser.close();
  }
}

console.log(JSON.stringify({ jar, psl }, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
import json
import os
import time

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]
SITE = "https://www.httpbin.org"

# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(engine, body):
    for attempt in range(1, 6):
        r = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=120)
        if r.ok:
            return r.json()
        if r.status_code != 503 or attempt == 5:
            raise RuntimeError(f"launch {r.status_code} {r.text}")
        time.sleep(int(r.headers.get("retry-after", 5)))

def open_browser(p, engine):
    session = launch(engine, {"proxy": PROXY, "headless": True, "record": False})
    return getattr(p, engine).connect(session["wsUrl"], headers={"x-api-key": KEY})

# httpbin answers 502/503 now and then: retry a page that isn't JSON.
def get_json(page, url):
    for attempt in range(1, 5):
        page.goto(url, timeout=45000)
        try:
            return json.loads(page.text_content("body"))
        except ValueError:
            if attempt == 4:
                raise
            time.sleep(3)

def names(cookies):
    return sorted(c["name"] for c in cookies)

with sync_playwright() as p:
    # Part 1: inspect, edit, clear and carry the jar (Chromium).
    jar = {}
    browser = open_browser(p, "chromium")
    try:
        context = browser.new_context()
        page = context.new_page()
        # The site sets two cookies (no expiry: session cookies); another site sets one more.
        get_json(page, f"{SITE}/cookies/set?session_token=abc123&theme=dark")
        context.add_cookies([{"name": "other_site", "value": "1", "url": "https://example.com"}])
        jar["all_cookies"] = names(context.cookies())
        mine = context.cookies(SITE)  # only the cookies that would be sent to this URL
        jar["for_site"] = names(mine)
        by_name = {c["name"]: c for c in mine}
        jar["session_cookie_expires"] = by_name["session_token"]["expires"]  # -1 = session cookie

        # Edit: add_cookies with the same name, domain and path replaces the cookie.
        week_from_now = int(time.time()) + 7 * 86400
        context.add_cookies([{**by_name["session_token"], "expires": week_from_now}, {**by_name["theme"], "value": "light"}])
        edited = {c["name"]: c for c in context.cookies(SITE)}
        jar["token_expires_in_days"] = round((edited["session_token"]["expires"] - time.time()) / 86400)
        jar["server_sees_after_edit"] = get_json(page, f"{SITE}/cookies")["cookies"]

        # Clear one cookie by name (other filters: domain, path).
        context.clear_cookies(name="theme")
        jar["after_clear_by_name"] = names(context.cookies())

        # Carry the login as plain JSON: just the site's cookies.
        saved = json.dumps(context.cookies(SITE), separators=(",", ":"))
        jar["saved_json_bytes"] = len(saved)
    finally:
        browser.close()

    browser = open_browser(p, "chromium")  # a new session on another browser
    try:
        context = browser.new_context()
        jar["new_session_before"] = names(context.cookies())
        context.add_cookies(json.loads(saved))
        page = context.new_page()
        jar["new_session_server_sees"] = get_json(page, f"{SITE}/cookies")["cookies"]
    finally:
        browser.close()

    # Part 2: the same three ways to set a cookie, on three hosts, in three engines.
    # httpbin.org and postman-echo.com are on the Public Suffix List; www.httpbin.org isn't.
    psl = []
    for engine in ["chromium", "firefox", "webkit"]:
        browser = open_browser(p, engine)
        try:
            for host in ["httpbin.org", "www.httpbin.org", "postman-echo.com"]:
                context = browser.new_context()
                page = context.new_page()
                url = f"https://{host}"
                context.add_cookies([{"name": "via_add_cookies", "value": "1", "url": url}])
                get_json(page, f"{url}/cookies/set?via_set_cookie=1")  # Set-Cookie, then a redirect to /cookies
                page.evaluate("() => { document.cookie = 'via_document_cookie=1; path=/; max-age=3600'; }")
                kept = names(context.cookies(url))
                psl.append({
                    "engine": engine,
                    "host": host,
                    "add_cookies": "via_add_cookies" in kept,
                    "set_cookie": "via_set_cookie" in kept,
                    "document_cookie": "via_document_cookie" in kept,
                    "server_sees": sorted(get_json(page, f"{url}/cookies")["cookies"].keys()),
                })
                context.close()
        finally:
            browser.close()

print(json.dumps({"jar": jar, "psl": psl}, indent=2))
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.Cookie;
import java.net.URI;
import java.net.http.*;
import java.util.*;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final String PROXY = System.getenv("PROXY_URL");
  static final String SITE = "https://www.httpbin.org";
  static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
  static final HttpClient HTTP = HttpClient.newHttpClient();

  // 503 means the fleet is momentarily busy: wait Retry-After and try again.
  static JsonObject launch(String engine, JsonObject body) throws Exception {
    for (int attempt = 1; ; attempt++) {
      HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
          .header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
      if (res.statusCode() != 503 || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
      Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  static Browser open(Playwright pw, String engine) throws Exception {
    JsonObject body = new JsonObject();
    body.addProperty("proxy", PROXY);
    body.addProperty("headless", true);
    body.addProperty("record", false);
    JsonObject session = launch(engine, body);
    BrowserType type = switch (engine) { case "firefox" -> pw.firefox(); case "webkit" -> pw.webkit(); default -> pw.chromium(); };
    return type.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
  }

  // httpbin answers 502/503 now and then: retry a page that isn't JSON.
  static JsonObject json(Page page, String url) throws Exception {
    for (int attempt = 1; ; attempt++) {
      page.navigate(url, new Page.NavigateOptions().setTimeout(45000));
      try {
        return JsonParser.parseString(page.textContent("body")).getAsJsonObject();
      } catch (JsonParseException | IllegalStateException e) {
        if (attempt == 4) throw e;
        Thread.sleep(3000);
      }
    }
  }

  static JsonArray names(List<Cookie> cookies) {
    JsonArray out = new JsonArray();
    cookies.stream().map(c -> c.name).sorted().forEach(out::add);
    return out;
  }

  static Cookie find(List<Cookie> cookies, String name) {
    return cookies.stream().filter(c -> c.name.equals(name)).findFirst().orElseThrow();
  }

  static boolean has(JsonArray names, String name) {
    return names.contains(new JsonPrimitive(name));
  }

  public static void main(String[] args) throws Exception {
    JsonObject jar = new JsonObject();
    JsonArray psl = new JsonArray();
    try (Playwright pw = Playwright.create()) {
      // Part 1: inspect, edit, clear and carry the jar (Chromium).
      String saved;
      Browser browser = open(pw, "chromium");
      try {
        BrowserContext context = browser.newContext();
        Page page = context.newPage();
        // The site sets two cookies (no expiry: session cookies); another site sets one more.
        json(page, SITE + "/cookies/set?session_token=abc123&theme=dark");
        context.addCookies(List.of(new Cookie("other_site", "1").setUrl("https://example.com")));
        jar.add("all_cookies", names(context.cookies()));
        List<Cookie> mine = context.cookies(SITE); // only the cookies that would be sent to this URL
        jar.add("for_site", names(mine));
        jar.addProperty("session_cookie_expires", find(mine, "session_token").expires.longValue()); // -1 = session cookie

        // Edit: addCookies with the same name, domain and path replaces the cookie.
        Cookie token = find(mine, "session_token");
        token.expires = (double) (System.currentTimeMillis() / 1000 + 7 * 86400);
        Cookie theme = find(mine, "theme");
        theme.value = "light";
        context.addCookies(List.of(token, theme));
        double expires = find(context.cookies(SITE), "session_token").expires;
        jar.addProperty("token_expires_in_days", Math.round((expires - System.currentTimeMillis() / 1000.0) / 86400));
        jar.add("server_sees_after_edit", json(page, SITE + "/cookies").get("cookies"));

        // Clear one cookie by name (other filters: domain, path).
        context.clearCookies(new BrowserContext.ClearCookiesOptions().setName("theme"));
        jar.add("after_clear_by_name", names(context.cookies()));

        // Carry the login as plain JSON: just the site's cookies.
        saved = new Gson().toJson(context.cookies(SITE));
        jar.addProperty("saved_json_bytes", saved.length());
      } finally {
        browser.close();
      }

      browser = open(pw, "chromium"); // a new session on another browser
      try {
        BrowserContext context = browser.newContext();
        jar.add("new_session_before", names(context.cookies()));
        context.addCookies(Arrays.asList(new Gson().fromJson(saved, Cookie[].class)));
        Page page = context.newPage();
        jar.add("new_session_server_sees", json(page, SITE + "/cookies").get("cookies"));
      } finally {
        browser.close();
      }

      // Part 2: the same three ways to set a cookie, on three hosts, in three engines.
      // httpbin.org and postman-echo.com are on the Public Suffix List; www.httpbin.org isn't.
      for (String engine : List.of("chromium", "firefox", "webkit")) {
        browser = open(pw, engine);
        try {
          for (String host : List.of("httpbin.org", "www.httpbin.org", "postman-echo.com")) {
            BrowserContext context = browser.newContext();
            Page page = context.newPage();
            String url = "https://" + host;
            context.addCookies(List.of(new Cookie("via_add_cookies", "1").setUrl(url)));
            json(page, url + "/cookies/set?via_set_cookie=1"); // Set-Cookie, then a redirect to /cookies
            page.evaluate("() => { document.cookie = 'via_document_cookie=1; path=/; max-age=3600'; }");
            JsonArray kept = names(context.cookies(url));
            JsonArray server = new JsonArray();
            json(page, url + "/cookies").getAsJsonObject("cookies").keySet().stream().sorted().forEach(server::add);
            JsonObject r = new JsonObject();
            r.addProperty("engine", engine);
            r.addProperty("host", host);
            r.addProperty("add_cookies", has(kept, "via_add_cookies"));
            r.addProperty("set_cookie", has(kept, "via_set_cookie"));
            r.addProperty("document_cookie", has(kept, "via_document_cookie"));
            r.add("server_sees", server);
            psl.add(r);
            context.close();
          }
        } finally {
          browser.close();
        }
      }
    }
    JsonObject out = new JsonObject();
    out.add("jar", jar);
    out.add("psl", psl);
    System.out.println(GSON.toJson(out));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
using System.Net.Http.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
const string Site = "https://www.httpbin.org";

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(string engine, JsonObject body)
{
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
        if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
        if ((int)res.StatusCode != 503 || attempt == 5) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
        await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
    }
}

async Task<IBrowser> Open(string engine)
{
    var session = await Launch(engine, new JsonObject { ["proxy"] = proxy, ["headless"] = true, ["record"] = false });
    var type = engine switch { "firefox" => playwright.Firefox, "webkit" => playwright.Webkit, _ => playwright.Chromium };
    return await type.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
}

// httpbin answers 502/503 now and then: retry a page that isn't JSON.
async Task<JsonObject> Json(IPage page, string url)
{
    for (var attempt = 1; ; attempt++)
    {
        await page.GotoAsync(url, new() { Timeout = 45000 });
        try { return JsonNode.Parse((await page.TextContentAsync("body"))!)!.AsObject(); }
        catch (JsonException) when (attempt < 4) { await Task.Delay(3000); }
    }
}

JsonArray Names(IEnumerable<BrowserContextCookiesResult> cookies) => new(cookies.Select(c => c.Name).Order(StringComparer.Ordinal).Select(n => (JsonNode)n!).ToArray());

// Part 1: inspect, edit, clear and carry the jar (Chromium).
var jar = new JsonObject();
string saved;
var browser = await Open("chromium");
try
{
    var context = await browser.NewContextAsync();
    var page = await context.NewPageAsync();
    // The site sets two cookies (no expiry: session cookies); another site sets one more.
    await Json(page, $"{Site}/cookies/set?session_token=abc123&theme=dark");
    await context.AddCookiesAsync(new[] { new Cookie { Name = "other_site", Value = "1", Url = "https://example.com" } });
    jar["all_cookies"] = Names(await context.CookiesAsync());
    var mine = await context.CookiesAsync(new[] { Site }); // only the cookies that would be sent to this URL
    jar["for_site"] = Names(mine);
    var token = mine.First(c => c.Name == "session_token");
    var theme = mine.First(c => c.Name == "theme");
    jar["session_cookie_expires"] = (long)token.Expires; // -1 = session cookie

    // Edit: AddCookiesAsync with the same name, domain and path replaces the cookie.
    var weekFromNow = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 7 * 86400;
    Cookie Copy(BrowserContextCookiesResult c) => new() { Name = c.Name, Value = c.Value, Domain = c.Domain, Path = c.Path, Expires = c.Expires, HttpOnly = c.HttpOnly, Secure = c.Secure, SameSite = c.SameSite };
    var editedToken = Copy(token); editedToken.Expires = weekFromNow;
    var editedTheme = Copy(theme); editedTheme.Value = "light";
    await context.AddCookiesAsync(new[] { editedToken, editedTheme });
    var expires = (await context.CookiesAsync(new[] { Site })).First(c => c.Name == "session_token").Expires;
    jar["token_expires_in_days"] = (long)Math.Round((expires - DateTimeOffset.UtcNow.ToUnixTimeSeconds()) / 86400.0);
    jar["server_sees_after_edit"] = (await Json(page, $"{Site}/cookies"))["cookies"]!.DeepClone();

    // Clear one cookie by name (other filters: domain, path).
    await context.ClearCookiesAsync(new() { Name = "theme" });
    jar["after_clear_by_name"] = Names(await context.CookiesAsync());

    // Carry the login as plain JSON: just the site's cookies.
    saved = JsonSerializer.Serialize((await context.CookiesAsync(new[] { Site })).Select(Copy));
    jar["saved_json_bytes"] = saved.Length;
}
finally { await browser.CloseAsync(); }

browser = await Open("chromium"); // a new session on another browser
try
{
    var context = await browser.NewContextAsync();
    jar["new_session_before"] = Names(await context.CookiesAsync());
    await context.AddCookiesAsync(JsonSerializer.Deserialize<List<Cookie>>(saved)!);
    var page = await context.NewPageAsync();
    jar["new_session_server_sees"] = (await Json(page, $"{Site}/cookies"))["cookies"]!.DeepClone();
}
finally { await browser.CloseAsync(); }

// Part 2: the same three ways to set a cookie, on three hosts, in three engines.
// httpbin.org and postman-echo.com are on the Public Suffix List; www.httpbin.org isn't.
var psl = new JsonArray();
foreach (var engine in new[] { "chromium", "firefox", "webkit" })
{
    browser = await Open(engine);
    try
    {
        foreach (var host in new[] { "httpbin.org", "www.httpbin.org", "postman-echo.com" })
        {
            var context = await browser.NewContextAsync();
            var page = await context.NewPageAsync();
            var url = $"https://{host}";
            await context.AddCookiesAsync(new[] { new Cookie { Name = "via_add_cookies", Value = "1", Url = url } });
            await Json(page, $"{url}/cookies/set?via_set_cookie=1"); // Set-Cookie, then a redirect to /cookies
            await page.EvaluateAsync("() => { document.cookie = 'via_document_cookie=1; path=/; max-age=3600'; }");
            var kept = (await context.CookiesAsync(new[] { url })).Select(c => c.Name).ToHashSet();
            var server = (await Json(page, $"{url}/cookies"))["cookies"]!.AsObject().Select(p => p.Key).Order(StringComparer.Ordinal);
            psl.Add(new JsonObject
            {
                ["engine"] = engine,
                ["host"] = host,
                ["add_cookies"] = kept.Contains("via_add_cookies"),
                ["set_cookie"] = kept.Contains("via_set_cookie"),
                ["document_cookie"] = kept.Contains("via_document_cookie"),
                ["server_sees"] = new JsonArray(server.Select(n => (JsonNode)n!).ToArray()),
            });
            await context.CloseAsync();
        }
    }
    finally { await browser.CloseAsync(); }
}

Console.WriteLine(new JsonObject { ["jar"] = jar, ["psl"] = psl }.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL (your own proxy; a few KB of traffic)
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"sort"
	"strconv"
	"time"

	"github.com/playwright-community/playwright-go"
)

var (
	key   = os.Getenv("CDPFLEET_API_KEY")
	proxy = os.Getenv("PROXY_URL")
)

const site = "https://www.httpbin.org"

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, opts map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(opts)
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
		req.Header.Set("x-api-key", key)
		req.Header.Set("content-type", "application/json")
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, err
		}
		data, _ := io.ReadAll(res.Body)
		res.Body.Close()
		if res.StatusCode == 200 {
			var out map[string]any
			return out, json.Unmarshal(data, &out)
		}
		if res.StatusCode != 503 || attempt == 5 {
			return nil, fmt.Errorf("launch %d %s", res.StatusCode, data)
		}
		wait, err := strconv.Atoi(res.Header.Get("retry-after"))
		if err != nil {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

func open(pw *playwright.Playwright, engine string) playwright.Browser {
	session, err := launch(engine, map[string]any{"proxy": proxy, "headless": true, "record": false})
	if err != nil {
		log.Fatal(err)
	}
	types := map[string]playwright.BrowserType{"chromium": pw.Chromium, "firefox": pw.Firefox, "webkit": pw.WebKit}
	browser, err := types[engine].Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		log.Fatal(err)
	}
	return browser
}

// httpbin answers 502/503 now and then: retry a page that isn't JSON.
func getJSON(page playwright.Page, url string) map[string]any {
	for attempt := 1; ; attempt++ {
		if _, err := page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(45000)}); err != nil {
			log.Fatal(err)
		}
		text, _ := page.TextContent("body")
		var out map[string]any
		if err := json.Unmarshal([]byte(text), &out); err == nil {
			return out
		} else if attempt == 4 {
			log.Fatal(err)
		}
		time.Sleep(3 * time.Second)
	}
}

func must[T any](v T, err error) T {
	if err != nil {
		log.Fatal(err)
	}
	return v
}

func names(cookies []playwright.Cookie) []string {
	out := []string{}
	for _, c := range cookies {
		out = append(out, c.Name)
	}
	sort.Strings(out)
	return out
}

func find(cookies []playwright.Cookie, name string) playwright.Cookie {
	for _, c := range cookies {
		if c.Name == name {
			return c
		}
	}
	log.Fatalf("no cookie %s", name)
	return playwright.Cookie{}
}

func contains(list []string, s string) bool {
	for _, v := range list {
		if v == s {
			return true
		}
	}
	return false
}

type pslRow struct {
	Engine         string   `json:"engine"`
	Host           string   `json:"host"`
	AddCookies     bool     `json:"add_cookies"`
	SetCookie      bool     `json:"set_cookie"`
	DocumentCookie bool     `json:"document_cookie"`
	ServerSees     []string `json:"server_sees"`
}

func main() {
	pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
	defer pw.Stop()

	// Part 1: inspect, edit, clear and carry the jar (Chromium).
	jar := map[string]any{}
	var saved []byte
	func() {
		browser := open(pw, "chromium")
		defer browser.Close()
		context := must(browser.NewContext())
		page := must(context.NewPage())
		// The site sets two cookies (no expiry: session cookies); another site sets one more.
		getJSON(page, site+"/cookies/set?session_token=abc123&theme=dark")
		must(0, context.AddCookies([]playwright.OptionalCookie{{Name: "other_site", Value: "1", URL: playwright.String("https://example.com")}}))
		jar["all_cookies"] = names(must(context.Cookies()))
		mine := must(context.Cookies(site)) // only the cookies that would be sent to this URL
		jar["for_site"] = names(mine)
		jar["session_cookie_expires"] = int(find(mine, "session_token").Expires) // -1 = session cookie

		// Edit: AddCookies with the same name, domain and path replaces the cookie.
		token := find(mine, "session_token").ToOptionalCookie()
		token.Expires = playwright.Float(float64(time.Now().Unix() + 7*86400))
		theme := find(mine, "theme").ToOptionalCookie()
		theme.Value = "light"
		must(0, context.AddCookies([]playwright.OptionalCookie{token, theme}))
		expires := find(must(context.Cookies(site)), "session_token").Expires
		jar["token_expires_in_days"] = int(math.Round((expires - float64(time.Now().Unix())) / 86400))
		jar["server_sees_after_edit"] = getJSON(page, site+"/cookies")["cookies"]

		// Clear one cookie by name (other filters: domain, path).
		must(0, context.ClearCookies(playwright.BrowserContextClearCookiesOptions{Name: "theme"}))
		jar["after_clear_by_name"] = names(must(context.Cookies()))

		// Carry the login as plain JSON: just the site's cookies.
		saved = must(json.Marshal(must(context.Cookies(site))))
		jar["saved_json_bytes"] = len(saved)
	}()
	func() {
		browser := open(pw, "chromium") // a new session on another browser
		defer browser.Close()
		context := must(browser.NewContext())
		jar["new_session_before"] = names(must(context.Cookies()))
		var loaded []playwright.Cookie
		must(0, json.Unmarshal(saved, &loaded))
		cookies := []playwright.OptionalCookie{}
		for _, c := range loaded {
			cookies = append(cookies, c.ToOptionalCookie())
		}
		must(0, context.AddCookies(cookies))
		page := must(context.NewPage())
		jar["new_session_server_sees"] = getJSON(page, site+"/cookies")["cookies"]
	}()

	// Part 2: the same three ways to set a cookie, on three hosts, in three engines.
	// httpbin.org and postman-echo.com are on the Public Suffix List; www.httpbin.org isn't.
	psl := []pslRow{}
	for _, engine := range []string{"chromium", "firefox", "webkit"} {
		func() {
			browser := open(pw, engine)
			defer browser.Close()
			for _, host := range []string{"httpbin.org", "www.httpbin.org", "postman-echo.com"} {
				context := must(browser.NewContext())
				page := must(context.NewPage())
				url := "https://" + host
				must(0, context.AddCookies([]playwright.OptionalCookie{{Name: "via_add_cookies", Value: "1", URL: playwright.String(url)}}))
				getJSON(page, url+"/cookies/set?via_set_cookie=1") // Set-Cookie, then a redirect to /cookies
				must(page.Evaluate("() => { document.cookie = 'via_document_cookie=1; path=/; max-age=3600'; }"))
				kept := names(must(context.Cookies(url)))
				server := []string{}
				for name := range getJSON(page, url+"/cookies")["cookies"].(map[string]any) {
					server = append(server, name)
				}
				sort.Strings(server)
				psl = append(psl, pslRow{engine, host, contains(kept, "via_add_cookies"), contains(kept, "via_set_cookie"), contains(kept, "via_document_cookie"), server})
				context.Close()
			}
		}()
	}

	out, _ := json.MarshalIndent(map[string]any{"jar": jar, "psl": psl}, "", "  ")
	fmt.Println(string(out))
}
```

## What we got

| Engine | Host | addCookies | Set-Cookie | document.cookie | Server received |
| --- | --- | --- | --- | --- | --- |
| chromium | httpbin.org | kept | kept | kept | 3 of 3 |
| chromium | www.httpbin.org | kept | kept | kept | 3 of 3 |
| chromium | postman-echo.com | kept | kept | kept | 3 of 3 |
| firefox | httpbin.org | kept | kept | kept | 3 of 3 |
| firefox | www.httpbin.org | kept | kept | kept | 3 of 3 |
| firefox | postman-echo.com | kept | kept | kept | 3 of 3 |
| webkit | httpbin.org | refused | refused | refused | 0 of 3 |
| webkit | www.httpbin.org | kept | kept | kept | 3 of 3 |
| webkit | postman-echo.com | refused | refused | refused | 0 of 3 |

From the Node.js run on 2026-10-17. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)

```
{
  "jar": {
    "all_cookies": [
      "other_site",
      "session_token",
      "theme"
    ],
    "for_site": [
      "session_token",
      "theme"
    ],
    "session_cookie_expires": -1,
    "token_expires_in_days": 7,
    "server_sees_after_edit": {
      "session_token": "abc123",
      "theme": "light"
    },
    "after_clear_by_name": [
      "other_site",
      "session_token"
    ],
    "saved_json_bytes": 151,
    "new_session_before": [],
    "new_session_server_sees": {
      "session_token": "abc123"
    }
  },
  "psl": [
    {
      "engine": "chromium",
      "host": "httpbin.org",
      "add_cookies": true,
      "set_cookie": true,
      "document_cookie": true,
      "server_sees": [
        "via_add_cookies",
        "via_document_cookie",
        "via_set_cookie"
      ]
    },
    {
      "engine": "chromium",
      "host": "www.httpbin.org",
      "add_cookies": true,
      "set_cookie": true,
      "document_cookie": true,
      "server_sees": [
        "via_add_cookies",
        "via_document_cookie",
        "via_set_cookie"
      ]
    },
    {
      "engine": "chromium",
      "host": "postman-echo.com",
      "add_cookies": true,
      "set_cookie": true,
      "document_cookie": true,
      "server_sees": [
        "__cf_bm",
        "_cfuvid",
        "via_add_cookies",
        "via_document_cookie",
        "via_set_cookie"
      ]
    },
    {
      "engine": "firefox",
      "host": "httpbin.org",
      "add_cookies": true,
      "set_cookie": true,
      "document_cookie": true,
      "server_sees": [
        "via_add_cookies",
        "via_document_cookie",
        "via_set_cookie"
      ]
    },
    {
      "engine": "firefox",
      "host": "www.httpbin.org",
      "add_cookies": true,
      "set_cookie": true,
      "document_cookie": true,
      "server_sees": [
        "via_add_cookies",
        "via_document_cookie",
        "via_set_cookie"
      ]
    },
    {
      "engine": "firefox",
      "host": "postman-echo.com",
      "add_cookies": true,
      "set_cookie": true,
      "document_cookie": true,
      "server_sees": [
        "__cf_bm",
        "_cfuvid",
        "via_add_cookies",
        "via_document_cookie",
        "via_set_cookie"
      ]
    },
    {
      "engine": "webkit",
      "host": "httpbin.org",
      "add_cookies": false,
      "set_cookie": false,
      "document_cookie": false,
      "server_sees": []
    },
    {
      "engine": "webkit",
      "host": "www.httpbin.org",
      "add_cookies": true,
      "set_cookie": true,
      "document_cookie": true,
      "server_sees": [
        "via_add_cookies",
        "via_document_cookie",
        "via_set_cookie"
      ]
    },
    {
      "engine": "webkit",
      "host": "postman-echo.com",
      "add_cookies": false,
      "set_cookie": false,
      "document_cookie": false,
      "server_sees": []
    }
  ]
}
```

## Takeaways

-   **`context.cookies(url)` is the filter you want:** the jar held three cookies, the site's URL matched two. Session cookies come back with `expires: -1`.
-   **Editing is adding again:** the same name, domain and path replace the cookie — the server saw `theme=light` and the token now expires in 7 days. `clearCookies({ name: 'theme' })` removed that one cookie and left the rest.
-   **A login travels as plain JSON:** 150–180 bytes of `context.cookies(url)` output (depending on the language's serializer), added to an empty context in a new session on another browser, and the site saw `session_token` again. That is the lightest way to carry state; [storageState](https://cdpfleet.com/docs/cases/persist-login) adds localStorage, and a [persistent profile](https://cdpfleet.com/docs/cases/persistent-profiles) keeps everything on our side.
-   **WebKit refuses every cookie on httpbin.org and postman-echo.com** — through `addCookies`, `Set-Cookie` and `document.cookie` alike — and keeps all three on www.httpbin.org. Both domains are on the [Public Suffix List](https://publicsuffix.org/) (its private section), and WebKit on Linux won't store a cookie for a public suffix. Chromium and Firefox kept host-only cookies there.
-   So if cookies work in Chromium but vanish in WebKit, check the domain against the Public Suffix List before blaming your code — and test cookie logic against a normal host such as www.httpbin.org.

---
Source: https://cdpfleet.com/docs/cases/inspect-and-edit-the-cookie-jar · Markdown: https://cdpfleet.com/docs/cases/inspect-and-edit-the-cookie-jar.md · All docs for AI tools: https://cdpfleet.com/llms.txt
