Playwright Local Proxy Pattern for Authenticated Web Apps
A reusable pattern for talking to authenticated web apps from local scripts without re-implementing their auth: a Playwright persistent browser context provides a real, logged-in session, and a tiny local FastAPI proxy forwards requests to the target with the captured auth attached.
Used so far for Slack, Kibana / OpenSearch, and Weibo. The same shape works for most cookie/SSO-authenticated web apps.
Why this pattern
- No need to register an OAuth app, get a workspace admin to approve a bot, or maintain refresh tokens.
- Works for internal corporate tools where SSO + IdP make programmatic access painful.
- Auth survives because the browser profile directory is reused across runs
(
launch_persistent_context(user_data_dir=...)). - Your scripts hit
http://localhost:<port>/...with no auth code at all.
Three flavors
The pattern adapts to how the target authenticates.
1. Bearer-token apps (e.g. Slack)
The token is reachable from localStorage or in-page JS state. Extract once,
write to a JSON file, replay forever (until the token rotates).
Runtime cost: the proxy is a pure HTTP forwarder. No browser running.
extract_token.py proxy.py (always-on)
───────────────── ───────────────────────
Playwright headed → FastAPI on 127.0.0.1:PORT
log in once load token + cookies from JSON
read localStorage forward /<path> → https://api.<host>/<path>
+ cookies inject Authorization: Bearer <token>
write auth.json attach cookies={d: ...}
Examples: Slack, Notion, Linear, Figma, Discord, most SaaS REST APIs that expose a usable session token to JS.
2. Cookie / SSO apps (e.g. Kibana)
Auth is HttpOnly cookies set by the SSO IdP. JS can’t read them, so the proxy must keep the browser context alive (or relaunch on expiry) and copy cookies out of the context into each forwarded request.
Runtime cost: the proxy embeds Playwright; one browser stays open.
proxy.py
────────
launch Edge persistent context (profile dir survives SSO)
goto target URL → wait for SSO redirect to finish
copy cookies into thread-safe cookie_jar
FastAPI on 127.0.0.1:PORT
forward request → cookies=cookie_jar
refresh cookies from context on each request (cheap)
Examples: Kibana, Grafana, Jira/Confluence behind SSO, Jenkins, AWS console, most corporate internal portals.
3. Hybrid / hard cases (e.g. Weibo, Xiaohongshu)
Requests are signed (XSRF, device fingerprint, anti-bot). Replaying raw HTTP from outside the browser fails. Fix: don’t replay — call the API from inside the page so the browser does the signing, cookies, and headers for you.
async def page_fetch(page, path):
js = """async (p) => {
const r = await fetch(p, {credentials:'include',
headers:{'Accept':'application/json'}});
return {status: r.status, body: await r.text()};
}"""
return await page.evaluate(js, path)
The wrapper script keeps the persistent context open and exposes a Python
function that internally drives page.evaluate(fetch(...)). No HTTP proxy
is needed — the “API” is just the Python function.
Examples: Weibo, Xiaohongshu, Douyin, banking dashboards, any site behind CloudFlare / Akamai / DataDome / per-request nonces.
Minimal Slack-style proxy (flavor 1)
# slack_proxy.py
import json, httpx, uvicorn
from pathlib import Path
from fastapi import FastAPI, Request, Response
AUTH_FILE = Path(__file__).parent / "slack_auth.json"
app = FastAPI()
state = {"token": "", "cookies": {}}
def load_auth():
data = json.loads(AUTH_FILE.read_text())
# Slack stores the xoxc- token in localStorage.localConfig_v2
lc = json.loads(data["localStorage"]["localConfig_v2"])
for tinfo in lc["teams"].values():
if tinfo["token"].startswith("xoxc-"):
state["token"] = tinfo["token"]; break
state["cookies"] = data["cookies"]
@app.api_route("/{path:path}", methods=["GET","POST","PUT","DELETE","PATCH"])
async def proxy(request: Request, path: str):
skip = {"host","cookie","authorization","content-length"}
headers = {k:v for k,v in request.headers.items() if k.lower() not in skip}
headers["authorization"] = f"Bearer {state['token']}"
body = await request.body()
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as c:
r = await c.request(request.method,
f"https://slack.com/api/{path}",
headers=headers, content=body or None,
cookies=state["cookies"],
params=dict(request.query_params))
return Response(r.content, r.status_code,
{k:v for k,v in r.headers.items()
if k.lower() not in {"content-encoding","transfer-encoding","content-length"}},
r.headers.get("content-type","application/json"))
if __name__ == "__main__":
load_auth()
uvicorn.run(app, host="127.0.0.1", port=19201, log_level="warning")
Consumers just do:
import httpx
httpx.get("http://localhost:19201/auth.test").json()
httpx.get("http://localhost:19201/conversations.list", params={"limit":5}).json()
Token extractor (flavor 1, one-time)
# extract_slack_token.py
from playwright.sync_api import sync_playwright
from pathlib import Path
import json
PROFILE = Path.home() / ".slack-proxy" / "edge-profile"
OUT = Path(__file__).parent / "slack_auth.json"
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(PROFILE), channel="msedge", headless=False)
page = ctx.new_page()
page.goto("https://app.slack.com/")
input("Log in in the browser, then press Enter here...")
storage = page.evaluate("Object.fromEntries(Object.entries(localStorage))")
cookies = {c["name"]: c["value"] for c in ctx.cookies()
if c["domain"].endswith("slack.com")}
OUT.write_text(json.dumps({"localStorage": storage,
"cookies": cookies}, indent=2))
ctx.close()
Minimal Kibana-style proxy (flavor 2)
Key differences vs flavor 1:
- Persistent context stays open for the proxy’s lifetime.
- A background task / per-request hook copies cookies from
ctx.cookies()into a shareddictunder athreading.Lock. - Restrict forwarding with an
ALLOWED_HOSTSset derived from the target URL, so the proxy can’t be abused as an open relay.
# pseudocode
with launch_persistent_context(profile_dir=~/.kibana-proxy/edge-profile) as ctx:
page = ctx.new_page(); page.goto(KIBANA_URL)
wait_for_sso_done(page) # url settles back to /app/home
refresh_cookies() # populate cookie_jar
@app.api_route("/{path:path}")
async def proxy(req, path):
refresh_cookies() # cheap
return await forward(req, f"{KIBANA_BASE}/{path}", cookies=cookie_jar)
Minimal Weibo-style wrapper (flavor 3)
# weibo_browser.py
from contextlib import contextmanager
from playwright.sync_api import sync_playwright
from pathlib import Path
PROFILE = Path("./profile")
@contextmanager
def weibo_context(headless=True):
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(PROFILE), headless=headless,
locale="zh-CN", timezone_id="Asia/Shanghai",
user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"))
ctx.add_init_script("""
Object.defineProperty(navigator, 'webdriver', {get:()=>undefined});
""")
try: yield ctx
finally: ctx.close()
def page_fetch(page, path):
js = """async (p) => {
const r = await fetch(p, {credentials:'include'});
return {status:r.status, body:await r.text()};
}"""
return page.evaluate(js, path)
Then any caller just opens a context, navigates to the host once so the visitor
cookies are minted, and calls page_fetch(page, "/api/...").
Where it works, where it doesn’t
Works:
- Anything where Chrome DevTools → Network shows plain JSON with cookies or a Bearer header.
- Internal corporate tools behind SSO (Kibana, Grafana, Jira, Jenkins, AWS).
- Most consumer SaaS (Slack, Notion, Linear, Figma, Discord).
- Sites with bot defenses, if you call from inside the page (flavor 3).
Doesn’t / risky:
- Mobile-only APIs with attestation (TikTok mobile, WeChat Pay, banking apps). The signing secrets aren’t in the browser.
- mTLS / smart-card / YubiKey-gated apps.
- Anything where ToS or law prohibits scraping/automation. The pattern is technically powerful and legally neutral — use it on systems you have a right to access.
Operational tips
- One profile dir per target, e.g.
~/.slack-proxy/edge-profile,~/.kibana-proxy/edge-profile,./weibo-tools/profile. Keeps cookies isolated and survives reboots. - Bind to
127.0.0.1only, never0.0.0.0. The proxy is unauthenticated on purpose. - Strip hop-by-hop headers (
host,content-length,connection,transfer-encoding,content-encoding) on both directions. - Healthcheck route (
/healthz) so consumer scripts can fail fast. - Token rotation: rerun the extractor; for cookie-based flows, just reopen the proxy and re-do SSO in the browser window.
- Anti-bot stealth: for flavor 3, set realistic UA / locale / timezone,
hide
navigator.webdriver, and reuse a real profile dir so fingerprints stay stable across runs.
Real instances in this repo / machine
| Target | Code | Auth storage | Port |
|---|---|---|---|
| Slack | ~/vwsre-project/slack_proxy.py | slack/meta/slack_auth.json | 19201 |
| Kibana | ~/vwsre-project/kibana_proxy.py | ~/.kibana-proxy/edge-profile/ | 19200 |
~/weibo-tools/src/weibo_tools/browser.py | ~/weibo-tools/profile/ | (in-process) |