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
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. 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
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.
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 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.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"] = 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 ?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.
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 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.
Allowed origins
Section titled “Allowed origins”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 (:443for https,:80for http) is dropped, sohttps://a.example:443andhttps://a.exampleare the same entry. - It applies to
JWTandWEBHOOKpolicies and runs before them: a disallowed site is refused without checking the token or calling your webhook.PUBLICcontent 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). AresolveViewerEndpointcall made from your own backend carries noOrigin, 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
OriginorRefererare 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.
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,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, andorigin-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.
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 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: 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-missing-exp | The token has no exp claim |
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 requiredClaimsJson entry didn’t match the token’s claim value |
jwt-bad-public-key | The stored signing key for the kid is not a parseable ES256 public key |
jwt-verify-error | Verification failed for a reason not listed above |
no-active-keys | Policy is JWT but no active signing keys are configured for the tenant |
webhook-no-url | Policy is webhook but has no URL configured |
policy-fetch-failed | The stream requires auth but its policy could not be loaded |
policy-marker-unknown | Whether the stream requires auth is unknown and its policy could not be loaded |
local-authority-denied | The edge’s local signed media authority denies playback, or has passed its validity |
origin-not-allowed | The viewer’s Origin (or Referer) is not in the policy’s allowedOrigins |
origin-missing | The policy restricts origins and the request carried no Origin or Referer |
origin-unobservable | The 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.
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 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.
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 |