Skip to content

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 any 2xx to allow or anything else to deny.

Either type can also restrict which websites may embed the content with allowed origins.

sequenceDiagram
    participant Viewer
    participant Gateway
    participant Policy as JWT key or webhook
    participant Edge as MistServer edge
    Viewer->>Gateway: Resolve playback ID + viewer credential
    Gateway->>Policy: Check access before returning an endpoint
    Policy-->>Gateway: Allow or deny
    Gateway-->>Viewer: Authorized playback URL
    Viewer->>Edge: Request media + viewer credential
    Edge->>Policy: Recheck on viewer connection
    Policy-->>Edge: Allow or deny
    Edge-->>Viewer: Media
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.

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. Every listed kid must name an active signing key; a blank or whitespace-only kid is rejected.

To require an audience or custom claim values, add requiredAudience and requiredClaimsJson. Each requiredClaimsJson entry is a PlaybackJwtClaimRequirementInput with the claim name and its expected value as JSON in jsonValue (a string value keeps its quotes):

mutation {
setPlaybackPolicy(
input: {
streamId: "stream_abc123"
policy: {
type: JWT
jwt: {
allowedKids: ["KID_FROM_STEP_1"]
requiredAudience: ["viewer"]
requiredClaimsJson: [{ name: "tier", jsonValue: "\"pro\"" }]
}
}
}
) {
... on Stream {
id
}
}
}

Setting a policy automatically:

  • Flips the stream’s requiresAuth marker to true.
  • Re-runs USER_NEW for 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.

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.

The TypeScript, Go, and Python SDKs include a signer that sets the kid header and these claims for you. Without an SDK, any ES256-capable JWT library works:

Node.js example (jsonwebtoken):

import jwt from "jsonwebtoken";
const privateKey = process.env.FW_SIGNING_PRIVATE_KEY; // PEM from step 1
const 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.requiredClaimsJson
},
privateKey,
{
algorithm: "ES256",
keyid: kid,
expiresIn: "5m",
}
);

Go example (golang-jwt/jwt/v5):

import (
"crypto/x509"
"encoding/pem"
"time"
"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"] = kid
signed, _ := t.SignedString(priv)

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 ?fwjwt=<token> to every resolved playback URL (HLS manifest, DASH manifest, WHEP offer, MP4 progressive). Every resolved URL also carries the playback’s session as tkn, which the edge uses to keep one viewer’s connections together; fwjwt carries the credential without replacing it. 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 ?fwjwt=<token> to the playback URL yourself (a ?jwt=<token> parameter is also accepted, but then the edge groups connections by the token instead of by playback), 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.

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 any 2xx 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
context: { courseId: "algebra-101", tier: "premium" }
}
}
}
) {
... 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.

context is optional: a JSON object of at most 4 KiB, stored with the policy and sent back to you in every access request. Put whatever your endpoint needs to decide there (a course, a product, a tier) so it doesn’t have to map playback IDs to your own records.

Your endpoint receives:

{
"streamName": "live+abc123",
"sessionId": "...",
"viewerIp": "203.0.113.42",
"requestUrl": "/abc123.m3u8?tkn=...&fwjwt=...",
"viewerToken": "<whatever the viewer attached, may be empty>",
"connector": "HLS",
"origin": "https://your-site.example",
"referer": "https://your-site.example/watch/42",
"context": { "courseId": "algebra-101", "tier": "premium" },
"timestamp": "2026-05-07T08:30:00Z"
}

origin and referer are the viewer request’s headers, empty when the request carried none (RTMP and SRT players, server-side calls). context is present only when the policy sets one.

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 = Buffer.from(req.get("x-frameworks-signature") ?? ""); // "sha256=<hex>"
const expected = Buffer.from(
"sha256=" +
crypto.createHmac("sha256", process.env.FW_WEBHOOK_SECRET).update(req.body).digest("hex")
);
// timingSafeEqual throws on buffers of different length, so check the length first.
if (sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) {
return res.sendStatus(403);
}
const payload = JSON.parse(req.body);
// ... your access logic ...
if (allowed) return res.sendStatus(204);
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 2xx (200, 201, 204, …) allows the viewer; anything else (403, other 4xx, 5xx, redirects, timeouts, network errors) denies.

A playback policy can list the websites allowed to embed its content. A browser tells the server which page a request comes from (the Origin header, or Referer), so a player copied onto another site is refused:

mutation {
setPlaybackPolicy(
input: {
streamId: "stream_abc123"
policy: {
type: JWT
jwt: {}
allowedOrigins: ["https://your-site.example", "https://app.your-site.example:8443"]
}
}
) {
__typename
}
}
  • Entries are scheme://host[:port] with no path, or * for any site. Up to 50 entries. A default port (:443 for https, :80 for http) is dropped, so https://a.example:443 and https://a.example are the same entry.
  • It applies to JWT and WEBHOOK policies and runs before them: a disallowed site is refused without checking the token or calling your webhook. PUBLIC content cannot set it.
  • Where it is checked. Every browser request is checked: endpoint resolution (resolveViewerEndpoint) and the media session on the edge (HLS, DASH, MP4, WHEP). A resolveViewerEndpoint call made from your own backend carries no Origin, so it is not checked; the viewer’s media session that follows is.
  • Viewers without a browser. RTMP, SRT, and other players that send no Origin or Referer are denied (origin-missing) unless the list contains *.
  • Not DRM. A client that is not a browser can send any Origin. Allowed origins stop other websites from embedding your player; combine them with a JWT or webhook policy for real access control.

Rotate signing keys periodically (we recommend quarterly):

  1. createSigningKey for the new key.
  2. Update your token-mint code to use the new private key + new kid.
  3. (Optional) update setPlaybackPolicy to add the new kid to allowedKids. If allowedKids is empty, all active tenant keys are accepted automatically.
  4. revokeSigningKey on the old key once all clients are minting under the new one. FrameWorks asks MistServer to invalidate active sessions on affected streams so USER_NEW re-runs and stale tokens are denied.

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).

  • Brief reconnect on policy change. Mutating a policy re-runs USER_NEW for 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_NEW against the fresh policy.
  • Distinct deny reasons. Logs distinguish missing-token, jwt-unknown-kid, jwt-wrong-alg, jwt-sig-fail, jwt-expired, jwt-claim-mismatch, webhook-deny-403, webhook-error-status, webhook-timeout, webhook-network, webhook-blocked-ssrf, origin-not-allowed, origin-missing, and origin-unobservable, among others, so you can tell customer misconfiguration from attack attempts from infrastructure errors. The full list is under Testing a policy.
  • 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.
  • ES256 only. Asymmetric is strictly better than HMAC for this use case (we never need your secret). HS256 is not supported.
  • Webhook responses are 2xx/non-2xx. 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: Bearer on 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.

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.

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:

ReasonMeaning
missing-tokenPolicy is JWT but no token was provided
jwt-not-a-jwsString isn’t a valid JOSE compact serialization
jwt-missing-kidHeader has no kid claim
jwt-unknown-kidkid doesn’t match any active signing key
jwt-wrong-algAlgorithm header isn’t ES256
jwt-sig-failSignature didn’t verify against the matching public key
jwt-missing-expThe token has no exp claim
jwt-expiredexp is in the past (60s skew tolerance)
jwt-not-yet-validnbf is in the future
jwt-aud-mismatchaud claim doesn’t match requiredAudience
jwt-claim-mismatchA requiredClaimsJson entry didn’t match the token’s claim value
jwt-bad-public-keyThe stored signing key for the kid is not a parseable ES256 public key
jwt-verify-errorVerification failed for a reason not listed above
no-active-keysPolicy is JWT but no active signing keys are configured for the tenant
webhook-no-urlPolicy is webhook but has no URL configured
policy-fetch-failedThe stream requires auth but its policy could not be loaded
policy-marker-unknownWhether the stream requires auth is unknown and its policy could not be loaded
local-authority-deniedThe edge’s local signed media authority denies playback, or has passed its validity
origin-not-allowedThe viewer’s Origin (or Referer) is not in the policy’s allowedOrigins
origin-missingThe policy restricts origins and the request carried no Origin or Referer
origin-unobservableThe policy restricts origins and the edge cannot report the viewer’s headers (an edge running a MistServer build older than v0.3.11)

To test allowed origins, pass origin (and optionally referer) in the testPlaybackAccess input. Without either, a policy that restricts origins reports origin-missing, as a request without those headers would. The origin check runs first, so a disallowed origin is reported without calling your 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 makes no request and returns allowed: false, policyType: "webhook", and reason: "webhook-test-skipped". It confirms that a webhook policy is in effect; it does not return the configured URL. 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.

GraphQLUse
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.playbackPolicyRead current policy (webhook secret masked)
VodAsset.playbackPolicyRead current policy (webhook secret masked)
Clip.playbackPolicyRead snapshotted clip policy