This document formalizes the security invariants and authentication policies for the xg2g system.
To protect against Cross-Site Request Forgery (CSRF) while maintaining API usability for non-browser clients, xg2g enforces a strict separation of authentication mechanisms based on the request type:
| Request Category | Target Path | Authentication Mechanism |
|---|---|---|
| Control Plane (API) | /api/v3/* |
Authorization: Bearer <Token> |
| Data Plane (Media) | /api/v3/sessions/{id}/hls/* |
Session Cookie (xg2g_session) |
- Bearer Tokens: Ideal for headless automation and non-browser clients. They are not susceptible to CSRF as they are not automatically sent by the browser.
- Session Cookies: Required for HLS playback in standard HTML5 video players which do not support custom headers for fragment requests. Cookies are scoped to the media domain to prevent cross-site leaks.
- Local loopback access (
http://localhost:8088on the same host) may stay on plain HTTP for smoke tests and one-box development. - Any browser-facing deployment reached from another device or hostname must be served over HTTPS, either directly in xg2g or through a trusted HTTPS proxy.
- Public/native reachability candidates for Android, TV, and browser-adjacent
bootstrap flows must come from explicit
connectivity.publishedEndpointsconfiguration. Clients do not invent origins from forwarded headers or Docker topology. - Public deployment contract evaluation is fail-closed:
- fatal findings fail startup
- degraded public findings block the affected runtime flow (
pairingorweb) - warnings stay visible through diagnostics without silently changing behavior
- When xg2g is intentionally deployed behind a trusted HTTPS proxy, the backend
hop from proxy to xg2g may remain plain HTTP on an internal network. Startup
cleartext-token warnings are therefore suppressed when
trustedProxiesis configured. Direct cleartext access to the xg2g listener is still an operator responsibility and should remain LAN-scoped or otherwise blocked. local_httppublished endpoints remain opt-in only viaconnectivity.allowLocalHTTP/XG2G_CONNECTIVITY_ALLOW_LOCAL_HTTPand are never advertised for browser cookie bootstrap.trustedProxiesmust never be world-open in public profiles.0.0.0.0/0and::/0are rejected by the contract because they let untrusted clients spoof HTTPS-offload headers.- Trust only addresses that actually terminate proxy connections. Never put a
WireGuard, Tailscale, or other VPN client subnet into
trustedProxies; VPN membership is not permission to assertX-Forwarded-*identity or scheme. - Keep the backend listener unreachable from untrusted hosts. The production
Compose default publishes
8088on host loopback only. A VPN-only deployment must replace that host bind with the server's exact VPN IP and enforce its WireGuard/Tailscale ACL or host firewall independently. - The WebUI uses React Router only as a client-side browser router. RSC,
server actions, and data-router action APIs are forbidden by
backend/scripts/verify-webui-router-security.shwhile the upstream RSC CSRF advisory has no published fixed release. - If xg2g sees a non-loopback browser request as plain HTTP,
POST /api/v3/auth/sessionfails closed with400 HTTPS required. Thexg2g_sessioncookie is not minted, native HLS media requests to/api/v3/sessions/{id}/hls/*fail authorization, and Safari/native players can collapse into generic playback errors such asVideo Error: 4.
Use connectivity.profile to declare which invariants must hold:
lan: no public origin requiredreverse_proxy: public HTTPS must exist andtrustedProxiesis mandatorytunnel: public HTTPS must exist andtrustedProxiesis mandatoryvps: public HTTPS must exist and xg2g must terminate TLS directly
The canonical operator diagnostic for this contract is:
GET /api/v3/system/connectivity
It reports:
- effective profile and severity
- startup/readiness/pairing/web blocking state
- trusted proxy configuration
- selected published endpoints for web/native/pairing
- request-scoped forwarded-header trust and effective HTTPS truth
Public reachability also activates an exposure policy model. Every v3 operation has an explicit exposure class, auth kind, browser-trust rule, rate-limit class, and audit requirement.
Public profiles additionally require:
- no wildcard browser origins
- published public web origins listed in
allowedOrigins - at least one scoped API token
- API tokens with at least 32 non-default characters
- legacy token sources disabled
- a playback decision secret when public streaming endpoints are published
- dedicated abuse-control limits and audit events for Pairing and DeviceAuth
- single-winner replay semantics for pairing exchange, web bootstrap completion, and rotating device grants
- versioned exposure audit events with trusted-proxy-resolved client IPs
See docs/ops/PUBLIC_EXPOSURE_SECURITY_CONTRACT.md for the endpoint exposure
policy model.
Browser-based clients (integrations) must use the following flow to obtain access to the Data Plane:
- API Authentication: The client authenticates against the Control Plane using a Bearer Token.
- Session Exchange: The client makes a
POSTrequest to/api/v3/auth/sessionwith the Bearer Token. - Cookie Issuance: The server validates the token and responds with a
Set-Cookieheader:- Name:
xg2g_session - HttpOnly:
true(Prevent XSS access) - SameSite:
Lax(Prevent cross-site ambient sends while preserving top-level navigation flows) - Path:
/api/v3/(Scoped to API/Media routes) - Secure:
trueon HTTPS or trusted HTTPS proxy requests - Transport Rule:
/api/v3/auth/sessionrejects plain HTTP unless the request originates from loopback (127.0.0.1/::1)
- Name:
- Media Access: Subsequent requests to
/api/v3/sessions/{id}/hls/*will include the cookie automatically.
Household protection is optional and operator-controlled.
- Without
household.pinorhousehold.pinHash, household profiles remain a convenience and scoping feature only. - With a configured PIN, protected household actions are enforced server-side: switching to adult profiles, household settings access, and logout from a child profile.
- Browser/UI prompts are only UX. The backend remains the source of truth.
Use exactly one of these YAML inputs:
household.pin: operator input in plaintext. The loader hashes it before the runtime config is stored.household.pinHash: pre-hashed form for deployments that never want the plaintext PIN written to disk.
household.pinHash is the only form that is persisted back out through config
management surfaces. Do not place a plaintext PIN in screenshots, examples,
support bundles, or logs.
Optional tuning:
household.unlockTTL: duration string controlling how long a successful household unlock stays active on the server. Default is4hand it is never allowed to outlive the authenticated session lifetime.
- Unlock state is stored server-side and bound to the
xg2g_household_unlockcookie. - The cookie is session-scoped in the browser and is also invalidated by the server-side unlock TTL.
- Unlock state ends on logout, explicit relock, browser-session end, or TTL expiry, whichever comes first.
- This is a practical household gate, not a hardened boundary against an already-unlocked and unattended client.
Legacy X-API-Token header/cookie sources are supported only for migration and can be disabled explicitly:
- Flag:
XG2G_API_DISABLE_LEGACY_TOKEN_SOURCES=true - Default:
true(legacy sources are disabled unless an operator explicitly re-enables them for migration) - Recommended rollout:
- Migrate clients to
Authorization: Bearer <token>(API) andxg2g_sessioncookie (media). - Keep
XG2G_API_DISABLE_LEGACY_TOKEN_SOURCES=true. - Monitor auth logs for
auth.legacy_token_sourcebefore and during cutover.
- Migrate clients to
The system implements a "Fail-Closed" policy for resource admission:
- State Unknown: If the system cannot determine the current resource usage (e.g., due to a database failure), it returns
503 Service Unavailablewith problem codeADMISSION_STATE_UNKNOWN. - Engine Disabled: If the streaming engine is disabled, all media requests are rejected with
503.
Live stream intent requests (POST /api/v3/intents) require a short-lived HS256 JWT signed by the
server. The signing key is a shared secret that must be configured before the service starts.
| Variable | Required | Min Length | Description |
|---|---|---|---|
XG2G_DECISION_SECRET |
Yes | 32 ASCII bytes | HMAC-SHA256 signing key for playback decision tokens. |
XG2G_RECORDINGS_TARGET_SIGNING_KEY |
Yes | 32 ASCII bytes | HMAC-SHA256 signing key for recording target profiles (protects HLS variant mappings). |
The service refuses to start if either of these variables is missing, empty, or whitespace-only.
The systemd ExecStartPre gate enforces the 32-byte minimum before any container is started.
Byte-count clarification: The length check uses
wc -c(raw byte count). For pure ASCII characters (hex, base64) one character equals one byte, so the check is unambiguous. Avoid Unicode or multi-byte characters in the secret value — use hex or base64 output as shown below.
Recommended: generate with openssl (produces unambiguous ASCII output):
# 256-bit key as hex (64 ASCII chars = 64 bytes — clearly above the 32-byte floor)
openssl rand -hex 32
# Alternative: base64url (43 ASCII chars = 43 bytes, 256 bits of entropy)
openssl rand -base64 32 | tr -d '=' | tr '+/' '-_'Add to /etc/xg2g/xg2g.env (root:root, mode 0600):
XG2G_DECISION_SECRET=<openssl-output>
XG2G_RECORDINGS_TARGET_SIGNING_KEY=<openssl-output>
secret="$(grep XG2G_DECISION_SECRET /etc/xg2g/xg2g.env | cut -d= -f2)"
printf '%s' "$secret" | wc -c # must be >= 32
rec_secret="$(grep XG2G_RECORDINGS_TARGET_SIGNING_KEY /etc/xg2g/xg2g.env | cut -d= -f2)"
printf '%s' "$rec_secret" | wc -c # must be >= 32Rotation model: single-key, restart-based.
JWT tokens are short-lived (≤ 120 s). This means the rotation window is at most 2 minutes, which is acceptable for a single-instance deployment. No dual-key scheme is implemented.
Rotation procedure:
- Generate new secrets:
openssl rand -hex 32 - Update
XG2G_DECISION_SECRETandXG2G_RECORDINGS_TARGET_SIGNING_KEYin/etc/xg2g/xg2g.env - Restart the service:
systemctl restart xg2g - Expected transient behaviour during restart: clients holding a token signed by the old key
will receive a
401 TOKEN_INVALID_SIGon their next intent request for up to one token TTL (≤ 120 s). This is by design. Compliant players re-initiate the intent flow automatically and receive a new token signed with the new key. No manual intervention is required; the error window is bounded by the TTL and does not persist beyond it.
Dual-key rotation (multi-instance only):
If you ever run more than one xg2g instance behind a load balancer, implement zero-downtime rotation
with a second variable XG2G_DECISION_SECRET_PREV:
- Deploy new instances with both
SECRET(new) andSECRET_PREV(old). - Verification accepts tokens signed by either key; new tokens are signed by
SECRETonly. - After all old-key tokens have expired (≤ 120 s), remove
SECRET_PREVand re-deploy.
This dual-key extension is not implemented in the current codebase — only add it if you scale to multiple instances. Single-instance operators: the restart procedure above is sufficient.
Feature flags are treated as a strict product surface:
- Registry Enforcement: All feature flag keys must be registered (Screaming Snake Case).
- Type Safety: Values must match registered types (e.g. Bool).
- Primitives Only: Values MUST be JSON primitives (bool, string, number, null). Nested structures are forbidden.
- Unknown/Invalid Keys: Result in
400 Bad RequestwithINVALID_INPUT.