Cases / #57 · 2026-10-17 · Easy
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.
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
- Chromium: the site sets
session_tokenandtheme(session cookies); a cookie for example.com is added too. Read the whole jar, then only the site's cookies. - Edit: give
session_tokenan expiry a week out and changethemetolightby adding them again; the site echoes what it received. - Delete
themeby name, save the site's cookies as JSON and close the session. - Launch a new session, add the saved cookies to an empty context and ask the site what it sees.
- In Chromium, Firefox and WebKit, set a cookie three ways —
addCookies, a serverSet-Cookieanddocument.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, 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:[email protected]:8000
// npm install [email protected]
// 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));
# 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))
// 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));
}
}
// 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 get github.com/playwright-community/[email protected]
// 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 withexpires: -1.- Editing is adding again: the same name, domain and path replace the cookie — the server saw
theme=lightand 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 sawsession_tokenagain. That is the lightest way to carry state; storageState adds localStorage, and a persistent profile keeps everything on our side. - WebKit refuses every cookie on httpbin.org and postman-echo.com — through
addCookies,Set-Cookieanddocument.cookiealike — and keeps all three on www.httpbin.org. Both domains are on the Public Suffix List (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.