Playback Access Control
By default, anyone with a stream’s playbackId can watch it. To restrict
playback to authenticated viewers (paid content, NFT-gated streams,
region-locked corporate video), attach a playback policy to the stream,
VOD asset, or clip. The policy follows the media: the same access model can
protect live streams, 24/7 DVR replay, clips, and uploaded VOD.
FrameWorks ships two policy types in v1:
- JWT — viewers present an ES256-signed JWT minted by your backend with a signing key you control. FrameWorks verifies the signature against the matching public key.
- Webhook — FrameWorks POSTs to a URL you specify on every viewer connect. Your endpoint returns 200 to allow or anything else to deny.
Quick start — JWT-signed playback
Section titled “Quick start — JWT-signed playback”1. Create a signing key
Section titled “1. Create a signing key”mutation { createSigningKey(input: { name: "primary" }) { ... on CreateSigningKeySuccess { signingKey { id kid publicKeyPem } privateKeyPem } }}privateKeyPem is returned once and never shown again. Capture it; if
lost, revoke and re-create. FrameWorks stores only the public PEM.
You can have up to 10 active signing keys per tenant. Use multiple keys for rotation — keep the old one active while clients update to the new one, then revoke.
2. Set the policy on a stream
Section titled “2. Set the policy on a stream”mutation { setPlaybackPolicy( input: { streamId: "stream_abc123" policy: { type: JWT, jwt: { allowedKids: ["KID_FROM_STEP_1"] } } } ) { ... on Stream { id playbackPolicy { type } } }}allowedKids empty = any active tenant key. Restricting it to specific kids
limits which keys can mint tokens for this object.
Setting a policy automatically:
- Flips the stream’s
requiresAuthmarker totrue. - Re-runs
USER_NEWfor every active viewer on that stream — viewers whose tokens still pass continue (with a brief reconnect blip on most protocols); viewers without valid tokens are denied.
3. Mint a viewer JWT on your backend
Section titled “3. Mint a viewer JWT on your backend”ES256 only. Header must include kid matching one of the policy’s allowed
kids. Standard claims: exp (required), iat, nbf, aud (if your policy
requires it), plus any custom claims your policy specifies.
Node.js example (jsonwebtoken):
import jwt from "jsonwebtoken";
const privateKey = process.env.FW_SIGNING_PRIVATE_KEY; // PEM from step 1const kid = process.env.FW_SIGNING_KID;
const token = jwt.sign( { sub: viewerId, // your internal viewer ID aud: "viewer", // optional, matches policy.requiredAudience tier: "pro", // optional, matches policy.requiredClaims }, privateKey, { algorithm: "ES256", keyid: kid, expiresIn: "5m", });Go example (golang-jwt/jwt/v5):
import ( "crypto/x509" "encoding/pem" "github.com/golang-jwt/jwt/v5")
block, _ := pem.Decode([]byte(privatePEM))priv, _ := x509.ParsePKCS8PrivateKey(block.Bytes)
t := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ "sub": viewerID, "exp": time.Now().Add(5 * time.Minute).Unix(),})t.Header["kid"] = kidsigned, _ := t.SignedString(priv)4. Attach the JWT to playback
Section titled “4. Attach the JWT to playback”The viewer’s player needs to send the JWT on the playback URL. FrameWorks
players accept it via the playbackAuth prop:
<Player contentId="abc123" options={{ playbackAuth: { token: viewerJwt }, }}/><fw-player content-id="abc123" playback-token="eyJ..."></fw-player>By default the player appends ?jwt=<token> to every resolved playback URL
(HLS manifest, DASH manifest, WHEP offer, MP4 progressive). You can also set
playbackAuth: { token: viewerJwt, transport: "header" } to send
Authorization: Bearer <token> on player-controlled request paths: HLS.js,
DASH.js, WHEP, and Mist JSON polling. Query-string delivery remains the
universal browser transport because native <video>, MP4 progressive,
native HLS, and WebSocket cannot attach arbitrary request headers.
When the player resolves endpoints through FrameWorks Gateway, it also
forwards the viewer JWT on the GraphQL resolve request. Protected objects are
checked before endpoint URLs and metadata are returned, then checked again by
MistServer’s USER_NEW path before media delivery.
If you’re not using a FrameWorks player, append ?jwt=<token> to the
playback URL yourself, and include X-Frameworks-Playback-JWT: <token> on
the resolveViewerEndpoint GraphQL request. Authorization: Bearer <token>
is also accepted on HTTP playback requests where headers are available.
Webhook-callback policies
Section titled “Webhook-callback policies”For more dynamic gating (per-viewer NFT checks, account-state lookups, geographic decisions, A/B tests), use the webhook policy type. FrameWorks POSTs to your URL on every viewer connect; you return 200 to allow or anything else to deny.
mutation { setPlaybackPolicy( input: { streamId: "stream_abc123" policy: { type: WEBHOOK webhook: { url: "https://your-app.example/playback-access" secret: "any-strong-random-secret" timeoutMs: 3000 } } } ) { ... on Stream { id } }}When updating an existing webhook policy, omit secret to keep the currently
configured HMAC secret. Send a new non-empty secret only when rotating it.
Your endpoint receives:
{ "streamName": "live+abc123", "sessionId": "...", "viewerIp": "203.0.113.42", "requestUrl": "/abc123.m3u8?jwt=...", "viewerToken": "<whatever the viewer attached, may be empty>", "connector": "HLS", "timestamp": "2026-05-07T08:30:00Z"}Headers include X-Frameworks-Signature: sha256=<hex>, an HMAC-SHA256 of
the raw request body using the secret you supplied. Verify this before
trusting the body — anyone who guesses your URL could otherwise call
your endpoint directly.
Node.js verification example:
import crypto from "crypto";
app.post("/playback-access", express.raw({ type: "application/json" }), (req, res) => { const sig = req.headers["x-frameworks-signature"]; // "sha256=<hex>" const expected = "sha256=" + crypto.createHmac("sha256", process.env.FW_WEBHOOK_SECRET).update(req.body).digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return res.sendStatus(403); }
const payload = JSON.parse(req.body); // ... your access logic ... if (allowed) return res.sendStatus(200); return res.sendStatus(403);});SSRF protection: setPlaybackPolicy rejects webhook URLs that resolve
to private/loopback/link-local/CGNAT addresses, and re-validates on every
dial to defeat DNS rebinding. Customer URLs must be public HTTPS endpoints.
Timeouts and limits: server caps timeoutMs at 10000ms (10s).
Response body is read up to 4 KiB only — return a status code, not a long
explanation. Any non-200 response (including 5xx, timeouts, network
errors, redirects) denies the viewer; only an explicit 200 allows.
Rotation
Section titled “Rotation”Rotate signing keys periodically (we recommend quarterly):
createSigningKeyfor the new key.- Update your token-mint code to use the new private key + new kid.
- (Optional) update
setPlaybackPolicyto add the new kid toallowedKids. IfallowedKidsis empty, all active tenant keys are accepted automatically. revokeSigningKeyon the old key once all clients are minting under the new one. FrameWorks asks MistServer to invalidate active sessions on affected streams soUSER_NEWre-runs and stale tokens are denied.
Removing a policy
Section titled “Removing a policy”Set the policy type back to PUBLIC:
mutation { setPlaybackPolicy(input: { streamId: "stream_abc123", policy: { type: PUBLIC } }) { __typename }}Active sessions are re-evaluated; viewers who were previously gated now play without a token (subject to a brief reconnect blip).
Behavior to expect
Section titled “Behavior to expect”- Brief reconnect on policy change. Mutating a policy re-runs
USER_NEWfor every active viewer on the affected stream. Most player stacks reconnect transparently; some may emit a one-time stall event. Customer-facing dashboards may see a viewer-count blip. - Per-session enforcement. The decision is made once per viewer
session (~10 min default), then cached by MistServer. Subsequent segment
fetches inherit the session decision without re-firing
USER_NEW. - Session invalidation is durable. Policy changes and key revokes
enqueue an invalidation row inside the same database transaction as the
underlying mutation, then attempt a synchronous fanout
(Commodore → Foghorn → Helmsman → MistServer) across every cluster the
tenant uses. If any cluster’s Foghorn or any node’s Helmsman is
unreachable, the row stays pending and a worker retries with exponential
backoff until every cluster acknowledges. Effective propagation under a
healthy network is sub-second; a partitioned cluster catches up as soon
as it is reachable again. Sessions on the affected edges drop and
re-fire
USER_NEWagainst the fresh policy. - Distinct deny reasons. Logs distinguish missing-token, bad-kid, wrong-alg, sig-fail, exp, claim-mismatch, webhook-deny-403, webhook-error-status, webhook-timeout, webhook-network, and webhook-blocked-ssrf so you can tell customer-misconfigured from attack-attempt from infrastructure-error.
- Derived asset boundary. Authorized endpoint resolution may return
thumbnail metadata and Chandler thumbnail URLs. Media playback is enforced
at resolve time and
USER_NEW; Chandler thumbnail URLs and already-shared Mist metadata/preview URLs do not independently recheck viewer auth in v1. Treat thumbnails as preview assets, not a DRM boundary.
Current Boundaries
Section titled “Current Boundaries”- ES256 only. Asymmetric is strictly better than HMAC for this use case (we never need your secret). HS256 is not supported.
- Webhook responses are 200/non-200. No fine-grained error code passthrough today — use the structured deny logs for diagnostics.
- Geo-block / IP allow-deny lists belong in your webhook callback when you need region or network-specific authorization.
- DRM (Widevine / FairPlay / PlayReady) is separate from signing-key access control and tracked on the product roadmap.
- Header transport on native browser paths. FrameWorks players support
Authorization: Beareron HLS.js, DASH.js, WHEP, and Mist JSON polling. Native<video>, MP4 progressive, native HLS, and WebSocket paths still require query-mode because browsers do not expose per-request headers there. - No Chandler-level thumbnail enforcement yet. Protecting derived assets end-to-end requires media-server and Chandler auth on those asset requests. v1 protects endpoint resolution and media sessions; derived asset enforcement is a separate rollout.
Testing a policy
Section titled “Testing a policy”testPlaybackAccess runs the same evaluator that the live USER_NEW path uses, against a token (or webhook test request) you supply, without registering a viewer session. Use it to confirm “this token would be accepted right now” or “my webhook endpoint is signing things correctly” before you ship a client change.
Mutation, not query — webhook mode (fireWebhook: true) makes a real outbound HTTPS request to your configured endpoint.
Test a JWT
Section titled “Test a JWT”mutation TestJWT { testPlaybackAccess( input: { playbackId: "<your playback id>" viewerToken: "eyJhbGciOiJFUzI1NiIs..." viewerIp: "203.0.113.42" connector: "hls" } ) { ... on PlaybackAccessDecision { allowed policyType reason # e.g. "jwt-expired", "jwt-aud-mismatch"; empty on allow detail # full verifier error string kid # key id the token claimed # On allow: verified claims. On deny: unverified parse of the payload # — useful for diagnosing aud / required-claim mismatches, never # trustworthy as an auth signal. claimsJson } ... on ValidationError { message field } ... on NotFoundError { message } }}Common deny reasons map 1:1 with what’s logged on real denies:
| Reason | Meaning |
|---|---|
missing-token | Policy is JWT but no token was provided |
jwt-not-a-jws | String isn’t a valid JOSE compact serialization |
jwt-missing-kid | Header has no kid claim |
jwt-unknown-kid | kid doesn’t match any active signing key |
jwt-wrong-alg | Algorithm header isn’t ES256 |
jwt-sig-fail | Signature didn’t verify against the matching public key |
jwt-expired | exp is in the past (60s skew tolerance) |
jwt-not-yet-valid | nbf is in the future |
jwt-aud-mismatch | aud claim doesn’t match requiredAudience |
jwt-claim-mismatch | A requiredClaims entry didn’t match the token’s claim value |
no-active-keys | Policy is JWT but no active signing keys are configured for the tenant |
Test a webhook
Section titled “Test a webhook”mutation TestWebhook { testPlaybackAccess( input: { playbackId: "<your playback id>" viewerIp: "203.0.113.42" connector: "hls" fireWebhook: true # explicit opt-in; without this, returns "webhook-test-skipped" } ) { ... on PlaybackAccessDecision { allowed policyType reason # "webhook-deny-403" | "webhook-timeout" | "webhook-blocked-ssrf" | ... webhookStatus # HTTP status returned by your endpoint webhookLatencyMs # end-to-end RTT } }}fireWebhook: false returns the resolved policy shape with reason: "webhook-test-skipped" so you can confirm what URL would be called without actually firing it. fireWebhook: true makes the same SSRF-hardened request the live evaluator uses — private/loopback/CGNAT IPs are blocked at dial time.
The webapp exposes both flows under the Playback Auth tab on a stream’s detail page.
API reference
Section titled “API reference”| GraphQL | Use |
|---|---|
createSigningKey(input: { name }) | Generate ES256 keypair, return private PEM once |
signingKey(id) | Fetch one signing key (tenant-scoped) |
signingKeysConnection(status, page) | Paginate keys; filter by "active" / "revoked" |
revokeSigningKey(id) | Mark revoked; triggers session re-evaluation |
setPlaybackPolicy(input) | Set policy on stream / vodAsset / clip |
testPlaybackAccess(input) | Dry-run the evaluator against a token / webhook |
Stream.playbackPolicy | Read current policy (webhook secret masked) |
VodAsset.playbackPolicy | Read current policy (webhook secret masked) |
Clip.playbackPolicy | Read snapshotted clip policy |