WaxSeal is a YouTube PO Token (POT) provider that runs Google's BotGuard in a real headless Chromium, driven over the Chrome DevTools Protocol by a repository-local standard-library client. It ships a bgutil-compatible HTTP daemon, a CLI, and reusable Go clients.
A real browser lets BotGuard inspect the actual navigator and reliably produce tokens with the integrity grade.
The container image bundles Chromium. To run the Go binary directly instead, the host needs a system Chromium (auto-detected; set
WAXSEAL_CHROME_BINto override), since the binary is not self-contained.
WaxSeal usually runs as a container (example compose file found in repo), and the published image bundles Chromium, so the host needs only Docker:
docker compose up -d # pulls ghcr.io/colespringer/waxseal and starts on 127.0.0.1:4416That pulls the :latest tag; pin a release with WAXSEAL_VERSION, for example
WAXSEAL_VERSION=1.0.0 docker compose up -d. To build the image from source instead
of pulling, run make docker-build first; it tags the same name locally.
The container is ready when its healthcheck passes. The daemon binds its socket
before browser startup but serves only once /ping returns {"ok":true,...};
startup attests the first tenant, caches a GVS token, and runs a full-length
streaming proof, usually 10-30 seconds. A mint failure stops startup; a failed
streaming proof is logged and retried by /player-context or /session. Once
ready, call the API:
curl -s localhost:4416/get_pot -d '{"content_binding":"<video_id>"}'
curl -s localhost:4416/session
curl -s localhost:4416/player-context -d '{"video_id":"<video_id>"}'
curl -s localhost:4416/ping
curl -s localhost:4416/metricsA PO token is bound to the minting host's egress IP, so a consumer that fetches
media must egress the same IP as WaxSeal. compose.full.yaml runs the daemon and
a consumer in one network namespace to guarantee that; point CONSUMER_IMAGE at
your application, and the daemon stays unpublished:
CONSUMER_IMAGE=your/image:tag docker compose -f compose.full.yaml upBoth compose.yaml (standalone) and compose.full.yaml extend the shared,
hardened compose.base.yaml; see those files for the read-only, resource-limit,
and multi-tenant options. Publishing beyond loopback requires API keys, described
under Authentication and tenants.
Build and run without Docker, on Linux or macOS (the daemon does not run on Windows). This path needs Go and a system Chromium:
go build ./...
go run ./cmd/waxseal server # start the daemon on 127.0.0.1:4416The CLI also runs one-shot commands, each against a fresh browser:
go run ./cmd/waxseal -c <content_binding> # one-shot token
go run ./cmd/waxseal player-context <video_id> # one-shot streaming context
go run ./cmd/waxseal doctor # report identity and token grade
go run ./cmd/waxseal ping # check a running daemonPrefer the warm daemon for repeated requests. Commands that take --video want a
bare video ID, not a URL.
doctor can also stop short of the full check. --skip-attest reports the
captured identity without attesting, leaving the attest key out of the report
rather than showing an empty grade. --stop-after-load stops earlier still, at
the load event of a page the command serves to itself on loopback, so it verifies
that Chromium renders and navigates with no external network at all;
--landing-url aims that check at some other page. Neither combines with
--full, which needs an attested session. The container image is smoke-tested
with waxseal doctor --stop-after-load on an isolated network.
| Method | Endpoint | Purpose |
|---|---|---|
POST |
/get_pot |
Mint or retrieve a cached PO token |
GET, POST |
/player-context |
Return an attested streaming context |
GET |
/session |
Export the attested guest identity and cookies |
POST |
/report |
Report a degraded stream and recycle its session |
GET |
/ping |
Check the current session without minting |
GET |
/metrics |
Operational counters; keyed daemons redact tenant detail |
Tokens and exported identities are bound to the minting host's egress IP, so the
consumer must issue SABR media requests from that same IP. The client package
mirrors these shapes and keeps the JSON tags in sync, so the fields below are
authoritative. Optional fields are marked. Errors use a JSON envelope, described
under Errors.
content_binding is the value the token binds to: a video ID for a player
token or visitor data for a GVS token, up to 4096 bytes. The optional scope
(player, gvs, pot, or omitted) only namespaces cache entries;
content_binding selects the token type. The response sets X-Pot-Cache: hit
when served from the cache or miss when freshly minted. A cache miss keeps a
fresh mint at least 12 seconds clear of the last context establishment on that
browser session, for the same grading reason described under /player-context
below, so a request that misses the cache just after any establishment on that
browser session, not only the startup proof, may wait up to that long.
POST /player-context {"video_id":"<id>"} or GET /player-context?video_id=<id>
returns the browser's streaming context. Select each audio_formats entry by its
full (itag, lmt, xtags) tuple, never by itag alone: a clean track and a DRC
track can share itag 251 and differ only in xtags, and an inconsistent tuple
makes the SABR server return a player-response reload instead of media.
playability_status is YouTube's string status (such as "OK"), not the SABR
status-1 protection code embedded in the signed URL.
// response
{
"playability_status": "OK",
"player_url": "https://www.youtube.com/s/player/<hash>/player_ias.vflset/en_US/base.js",
"server_abr_streaming_url": "https://...&n=<scrambled>", // descramble n with player_url before use
"video_playback_ustreamer_config": "<base64>",
"visitor_data": "<base64>",
"client_version": "2.YYYYMMDD.NN.NN",
"title": "<video title>",
"author": "<channel name>",
"length_seconds": 634,
"audio_formats": [
{
"itag": 251,
"lmt": "1699999999999999",
"xtags": "", // clean track
"mime_type": "audio/webm; codecs=\"opus\"",
"bitrate": 130000,
"content_length": 10318791,
"approx_duration_ms": 634601,
"audio_sample_rate": 48000,
"audio_channels": 2,
"audio_quality": "AUDIO_QUALITY_MEDIUM",
"is_drc": false,
"audio_track_id": "" // empty for the default or only track
},
{
"itag": 251, "lmt": "1699999999999999", "xtags": "CggKA2RyYxIBMQ", "is_drc": true
// same itag and lmt as the clean track, different xtags: the DRC variant.
// Remaining fields as above. Select by the full tuple, never itag alone.
}
],
"session_generation": 1
}Two things happen before the daemon serves a context. It proves full-length
streaming once per browser session, on the landing video or, if that one is
unavailable or too short, a fallback candidate, because a context that is the
session's first playback is graded as a preview about as often as not. A session
that cannot prove it is refused rather than served, and a failed proof is not
retried on every request: it refuses immediately for the next 30 seconds without
another attempt, and if the proof still fails once that cool-down has passed, the
session is relaunched once and the fresh session is proved in its place, refusing
only if that also fails. The refusal arrives as the player-context-failed error
(502) and is safe to retry once the cool-down passes, so a consumer does not need
to treat it as a problem with the video. It also keeps the served context at
least 12 seconds away from the last token mint or proof playback on that browser
session, and keeps a token mint the same distance from the last establishment,
because a context taken within a few seconds of either is graded the same way.
Contexts served earlier do not extend that window, so back-to-back requests are
not delayed by one another. The first context after startup or a relaunch may
wait for both steps; later requests normally find the session proved and the
window already clear. A context that clears both steps is then served without
any further, per-request check: the daemon removes the measured cause of a
graded preview and refuses when it cannot prove the session, but it does not
itself grade the URL it hands out. The startup self-test performs the proof
before the daemon accepts traffic. WAXSEAL_MINT_SEPARATION overrides the
spacing with any positive Go duration, for example 20s.
Exports the guest identity for the session-adoption path (--session-url plus
--potoken-url), after verifying full-length streaming with the same cool-down
and one-relaunch-per-streak policy described under /player-context. No request
body, and no Google login. If the startup self-test already proved the session
this call is immediate; if it did not, this call performs the proof itself, and
unlike a served context it is not held back by the mint-separation window, since
/session hands out the identity itself rather than a context tied to a recent
mint. A session that cannot prove full-length streaming is refused as
no-session (503) rather than exported.
// response
{
"visitor_data": "<base64>",
"user_agent": "Mozilla/5.0 ...",
"client_version": "2.YYYYMMDD.NN.NN",
"cookies": [
{
"name": "VISITOR_INFO1_LIVE",
"value": "...",
"domain": ".youtube.com",
"path": "/",
"secure": true,
"http_only": true,
"same_site": "None", // optional: "Strict" | "Lax" | "None"; omitted when unset
"expires": "2035-01-02T03:04:05Z" // optional RFC3339; omitted for session cookies
}
],
"cookie_header": "VISITOR_INFO1_LIVE=...; YSC=...",
"session_generation": 1
}Report a degraded stream by the session_generation from /session or
/player-context. session_generation is required; optional video_id and
reason must be 1-64 characters from [A-Za-z0-9_-]. Reports are scoped and
rate-limited per tenant: report-driven recycles draw from a budget of 4 that
refills at one per --report-debounce (default 5m). A report past the budget
is rejected with retry_after_seconds, and stale or future generations are
ignored.
// request
{"session_generation": 1, "video_id": "<id>", "reason": "truncated"} // video_id, reason optional
// response
{
"accepted": false,
"retired": false,
"retirement_pending": false,
"generation": 1,
"retry_after_seconds": 300 // optional; only when rate-limited
}/metrics counts each report by disposition: degradation_reports_accepted
(applied to the live session), degradation_reports_rate_limited (past the
report budget), degradation_reports_rejected_stale (an old or replaced
generation), degradation_reports_already_retired (the current generation,
already retired by a crash or a prior report; a benign no-op), and
degradation_reports_duplicate_pending (a repeat report for a generation whose
retirement is already queued for the next streaming handoff).
The daemon is keyless and single-tenant by default. Pass --tenant-keys to run
isolated browser contexts keyed by API key:
go run ./cmd/waxseal server --tenant-keys "alice=KEYA,bob=KEYB"
curl -s localhost:4416/get_pot -H "X-API-Key: KEYA" -d '{"content_binding":"<id>"}'Keys travel in X-API-Key, Authorization: Bearer <key>, or ?key=<key>.
Prefer a header. ?key=<key> puts the key in the request line, which reverse
proxies and container runtimes write to their access logs, so a health check
polling every few seconds leaves the key in those logs for the life of the
deployment. waxseal ping --key and the client package both send the header.
--tenant-keys takes comma-separated label=key entries or bare keys (which get
generated labels); labels and keys must be non-empty and unique, and an invalid
set stops startup before Chromium launches. A keyless daemon on a non-loopback
host exposes its guest identity through /session and /player-context, so use
--tenant-keys when exposing the service.
/metrics reports operational counters and always returns HTTP 200; redaction is
a successful response, not a 401. On a keyed daemon it is redacted by
default and unlocks only for the operator key or an explicit public flag:
| Daemon / request | /metrics returns |
|---|---|
| keyless (default) | full per-tenant detail |
| keyed, no key / tenant key / wrong key | redacted aggregate: daemon-wide summed counters, no labels, no tenant count |
keyed, correct --metrics-key |
full per-tenant detail |
keyed, --metrics-public |
full per-tenant detail, unauthenticated |
Tenant keys never unlock detail; only --metrics-key (which must differ from
every tenant key) or --metrics-public does, keeping minting keys separate from
metrics access. When both are set, --metrics-public wins. Both are ignored on a
keyless daemon.
The full view is {"tenants":N,"per_tenant":{"<label>":{...}}}, each tenant
object carrying lifetime counters (mints, crashes, player_contexts,
separation_waits for requests held back to keep a mint and an establishment
apart, unproven_rejections for contexts refused because the session could not
prove full-length streaming, the five degradation_reports_* dispositions above,
and so on) plus current state. Detail fields are always present so the schema
stays stable across retirement, crash, and recycle; a field that does not apply
is null or "" rather than omitted. For example last_browser_proof_age_secs
is null until the first proof, which reserves 0 for "just proved", and
streaming_seconds_until_recycle appears only when time-based recycling is
enabled (--streaming-max-age > 0). The redacted view is
{"redacted":true,"aggregate":{...}}: the same counters summed across tenants,
with no labels and no tenant count.
Recognized endpoints and unknown paths return
{"error":"<message>","code":"<machine-readable-code>"}. video-unavailable
adds a details field with the playability status. /ping never uses this
envelope; it reports health directly (see Operations).
| Code | HTTP | Meaning |
|---|---|---|
invalid-request |
400 | Malformed or invalid input |
unauthorized |
401 | Missing or invalid API key |
not-found |
404 | Unknown path or endpoint |
method-not-allowed |
405 | Unsupported HTTP method |
video-unavailable |
422 | Terminal playability status |
mint-failed, player-context-failed |
502 | Upstream operation failed |
no-session |
503 | No attested session is available |
timeout |
504 | Deadline elapsed for /get_pot, /player-context, or /session |
Two cases skip the envelope, both handled by http.ServeMux before any WaxSeal
handler runs. A non-canonical path (with ., .., or repeated slashes, such as
//get_pot) gets a 307 redirect to its cleaned form with the short
text/html or empty body that http.Redirect produces, so a client that does
not follow redirects must not expect JSON there. A trailing slash is a distinct
path, so /get_pot/ returns the structured 404.
/report decodes strictly: an unknown field, often a typo such as raeson for
reason, is rejected with 400 invalid-request naming the key, since its
optional fields would otherwise be dropped silently. /get_pot and
/player-context stay lenient and ignore unknown fields, because /get_pot must
tolerate the extra fields a generic yt-dlp client sends (proxy, bypass_cache,
source_address) and /player-context reads video_id from the body or the
query string. Duplicate keys are lenient everywhere, since encoding/json keeps
the last value. The client package parses these into *client.APIError with
matching code constants.
One Chromium process hosts an isolated incognito context per tenant; additional tenants attest on their first token, player-context, or session request.
WaxSeal launches Chromium over a CDP pipe. On normal teardown it terminates
Chromium's process group and removes the profile, and a clean exit also lets
Chromium read EOF on the closed pipe and quit. If the daemon dies without
teardown (SIGKILL, OOM), a browser may linger briefly; the next startup removes
abandoned WaxSeal profile directories it can prove are unused, without scanning or
killing processes, and Chromium generally exits once its profile is gone.
Profiles live under $HOME so snap-confined Chromium can open them and so shared
hosts keep each daemon's profiles private.
The crashes metric counts unexpected browser loss from Chromium events or a
failed health probe, not retirement from age, a report, or operation retries.
--report-debounce (default 5m) throttles all report-driven recycles for a
tenant across generations, not just repeats of one generation. Bursts of up to 4
recycles are allowed before the limit bites, enough for a consumer whose
bulk-enumeration throttle escape rotates its identity several times in quick
succession; past the burst, the budget refills at one recycle per interval. This
is deliberate anti-storm behavior; workloads that recycle faster on a sustained
basis may lower it.
Health checks use /ping, which after authentication returns HTTP 200 with
ok:true or ok:false and an always-present reason: ok, no-session
(benign, since a POST /report retires the session and re-establishment is lazy,
so ok briefly reads false), or probe-failed (a live session's probe failed,
logged at warn). Alert only on probe-failed; a caller that disconnects
mid-probe is not counted as one. For status-code-only checks (k8s, curl -f,
HAProxy), ?strict=true maps probe-failed to 503 while no-session and
healthy stay 200, and waxseal ping --strict does the same from the CLI, so
liveness probes do not fail during the benign re-establishment window. A bare
?strict also enables it; a value strconv.ParseBool cannot read (yes, on,
banana) returns 400 rather than quietly running non-strict, so a typo in a
probe is visible. The image's HEALTHCHECK runs waxseal ping --strict for
exactly that reason; multi-tenant deployments must add --key <key> to it, which
sends the key as a header and keeps it out of access logs.
Headless Chromium reports a HeadlessChrome token in navigator.userAgent and
in its brand list, so WaxSeal installs a user-agent override that substitutes
Chrome for it. Everything else in that override is the browser's own
navigator.userAgentData, read back from a page WaxSeal serves to itself on
loopback (the API is exposed only in a secure context, and the about:blank the
override has to be installed on is not one). That keeps the randomised GREASE
brand, the four-part build version, and the real platform, architecture, and
bitness, all of which a fabricated block gets wrong in stable and inspectable
ways. WAXSEAL_UA_HINTS=synthetic restores the fabricated block if the real one
ever grades worse; real is the default and any other value is ignored with a
warning. Note that a Debian chromium build, which the image runs, reports no
Google Chrome brand at all while its user agent still says Chrome/<version>.
That is what real Debian Chromium looks like, not a bug.
WaxSeal is meant for loopback or a trusted network and does not implement CORS;
because it mints tokens, browser-origin access is out of scope. Run
go run ./cmd/waxseal server --help for the rest: session recycling, report
debounce, bind address, headful mode, and metrics access.
go test ./... # offline unit tests; no browser or network
go test -tags live ./internal/cdp # real-Chromium CDP pipe-transport tests
(cd provider && go test -tags e2e ./...) # provider network e2e; needs WAXSEAL_URL/WAXSEAL_KEY
make deps # install browser-bundle build dependencies
make jsbundle-browser # regenerate internal/browser/bg_browser_bundle.jsgo test ./... is fully offline and deterministic: no browser, no network. The
committed browser bundle means normal builds do not need Node. The live CDP tests
self-skip when no browser is found (WAXSEAL_CHROME_BIN picks one,
WAXSEAL_REQUIRE_CHROME=1 fails instead of skipping, which CI sets). The e2e
tests live in the nested provider/ module and must run from that directory,
since a root-level go test -tags e2e ./... silently descends into nothing; they
need a warm daemon and include the full-length WEB SABR download
(go test -tags e2e -run PlayerContextOnlyFullLength ./...). Set
WAXSEAL_E2E_LOG_LEVEL=debug to see the in-process daemon's debug logs in
go test -v output for that suite. TestAgingMatrix is a separate, opt-in
measurement of how an artifact's age affects a capped stream, not a regression
test: it skips unless WAXSEAL_E2E_AGING=1 (which artifact's age predicts a
truncated stream) or =2 (how much separation between a mint and a served
context is enough) is set, runs for tens of minutes, and only ever reports a
tally, never a pass/fail on truncation. WAXSEAL_E2E_AGING_N overrides the
per-arm iteration count (default 6) and WAXSEAL_E2E_AGING_DELAY overrides the
run-wide delay between warming and streaming (default 30s; an arm carrying its
own delay ignores it). By default every in-process daemon the suite starts keeps
its own mint-to-establishment gate (12s unless WAXSEAL_MINT_SEPARATION
overrides it), so a default run is really a regression check: every arm is
expected to stream full length. WAXSEAL_E2E_AGING_SEPARATION (a Go duration
such as 1ms) overrides that gate on those daemons so the arms measure raw gaps
again, the way the matrix originally separated them; because attestation always
pre-mints a token, the token age arms then measure time since attestation rather
than since their own mint call, which usually just returns that cached token.
The client package is a reusable, consumer-agnostic HTTP client; the
provider/ module adapts it to the token-provider interface a streaming
consumer expects.
CLI exit codes: 0 success, 1 runtime failure, 2 usage error, 3 unavailable
video, 130 interruption.
Some coverage stays out of go test ./... because it needs a display or a long
run: headful mode (go run ./cmd/waxseal server --headful) to watch a real
session, a time-based recycling soak (a short --streaming-max-age with
continuous streaming to watch streaming_seconds_until_recycle), and a
cache-exhaustion loop (POST /get_pot 1000+ times with distinct
content_binding values to exercise cache eviction).
MIT. Implemented independently. The GPL-3.0 bgutil project is a behavioral and wire reference only. See THIRD-PARTY-NOTICES.md.