TL;DR
We'll build the backend for a members-only video library: upload assets as private, create an RSA signing key, mint a short-lived JWT per viewer in a FastAPI endpoint, and hand the player a stream URL plus a thumbnail URL that both carry the token. Then we'll prove the thumbnail is locked too, because that's the one everybody forgets.
The naive version of a paywalled video library is: check the session, render a player, done. The .m3u8 URL in that player is public. Anyone who copies it from DevTools has your content forever.
The fixed version has three parts: the asset has no public address, every playback URL is a token minted for one viewer and expiring soon, and the stills (thumbnail, scrub-bar spritesheet) require the same token. We'll use FastPix as the video API because its signing-key flow is a single endpoint and the thumbnails honour the playback token. The pattern is the same shape on Mux or Cloudflare Stream; only the claim names change.
You need python 3.11+ (the FastPix SDK itself supports 3.9.2 or later), a FastPix account with an Access Token ID and Secret Key, and a public URL for a webhook (ngrok is fine for dev).
1. 🧱 Project setup
mkdir members-video && cd members-video
python -m venv .venv && source .venv/bin/activate
pip install fastapi "uvicorn[standard]" httpx "pyjwt[crypto]" fastpix-python python-dotenv
# .env
FASTPIX_TOKEN_ID=...
FASTPIX_SECRET=...
FASTPIX_SIGNING_KEY_ID= # filled in step 2
FASTPIX_PRIVATE_KEY_B64= # filled in step 2
DATABASE_URL=sqlite:///./library.db
The [crypto] extra on PyJWT matters: RS256 needs the cryptography package, and without it you get NotImplementedError: Algorithm 'RS256' could not be found at the worst possible time.
2. 🔑 Create a signing key (once)
A signing key on FastPix is an RSA key pair. You call the endpoint, the API generates a 2048-bit pair, returns the private half to you once (base64-encoded) and keeps only the public half for verification. It does not save your private key, so if you lose it, you make a new key.
curl -s -X POST https://api.fastpix.com/v1/iam/signing-keys \
-u "$FASTPIX_TOKEN_ID:$FASTPIX_SECRET" | python -m json.tool
{
"success": true,
"data": {
"id": "fc9d9368-6ee5-4b16-ae50-880a2374bdc4",
"privateKey": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2...",
"createdAt": "2026-10-05T09:14:06.618993Z"
}
}
Put data.id into FASTPIX_SIGNING_KEY_ID and data.privateKey into FASTPIX_PRIVATE_KEY_B64. Decode it once at startup:
# app/signing.py
import base64, os, time
import jwt
SIGNING_KEY_ID = os.environ["FASTPIX_SIGNING_KEY_ID"]
PRIVATE_KEY_PEM = base64.b64decode(os.environ["FASTPIX_PRIVATE_KEY_B64"]).decode()
def playback_token(playback_id: str, *, ttl_seconds: int = 3600) -> str:
now = int(time.time())
payload = {
"kid": SIGNING_KEY_ID,
"aud": f"media:{playback_id}",
"iat": now,
"exp": now + ttl_seconds,
}
return jwt.encode(payload, PRIVATE_KEY_PEM, algorithm="RS256",
headers={"kid": SIGNING_KEY_ID})
Three claims do the work. kid names the key so you can rotate later. aud is media:<playbackId>, which scopes the token to one asset. exp is the whole revocation story (more on that in step 6).
⚠️ Note: the FastPix JWT guide shows a generic HS256 example with a shared secret. The key the API issues you is an RSA private key and FastPix verifies with the stored public key, so the matching algorithm is RS256. If a token is rejected, test the same inputs in their hosted signer at jwt.fastpix.co and compare the decoded header.
3. 📤 Upload assets as private
We'll ingest from a URL with the official Python SDK. The one field that matters for this tutorial is access_policy="private". A private asset gets no public playback ID at all.
# app/ingest.py
import os
from fastpix_python import Fastpix, models
def create_private_media(source_url: str, title: str, course_id: str) -> dict:
with Fastpix(security=models.Security(
username=os.environ["FASTPIX_TOKEN_ID"],
password=os.environ["FASTPIX_SECRET"],
)) as fastpix:
res = fastpix.input_video.create_media(
inputs=[{"type": "video", "url": source_url}],
access_policy="private",
metadata={"courseId": course_id, "title": title},
)
return res.model_dump(mode="json", by_alias=True)["data"]
The create response's data is an object (not an array) carrying id, status and playbackIds[]. Store id against your lesson row now; the playback ID is in that list, but don't serve it until the asset is ready.
Readiness arrives on a webhook. Point your FastPix workspace's webhook URL at /webhooks/fastpix and handle the video.media.ready event:
# app/webhooks.py
from fastapi import APIRouter, Request
from .db import mark_ready
router = APIRouter()
@router.post("/webhooks/fastpix")
async def fastpix_webhook(request: Request):
event = await request.json()
if event.get("type") == "video.media.ready":
media = event["data"]
mark_ready(media_id=media["id"],
playback_id=media["playbackIds"][0]["id"])
return {"ok": True}
💡 Tip: treat the webhook as idempotent. Processing-pipeline events can arrive more than once;
mark_readyshould be an UPDATE that's safe to repeat.
4. 🎟️ The playback endpoint: membership check, then mint
This is the only place tokens get created. The membership check happens first; the token is proof that it passed.
# app/main.py
from fastapi import FastAPI, Depends, HTTPException
from .auth import current_user # your session logic
from .db import get_lesson, user_can_watch
from .signing import playback_token
from .webhooks import router as webhooks
app = FastAPI()
app.include_router(webhooks)
STREAM = "https://stream.fastpix.com/{pid}.m3u8?token={tok}"
THUMB = "https://images.fastpix.com/{pid}/thumbnail.jpg?token={tok}"
SPRITE = "https://images.fastpix.com/{pid}/spritesheet.jpg?token={tok}"
@app.get("/api/lessons/{lesson_id}/playback")
def playback(lesson_id: int, user=Depends(current_user)):
lesson = get_lesson(lesson_id)
if not lesson or not lesson.playback_id:
raise HTTPException(404)
if not user_can_watch(user.id, lesson.course_id):
raise HTTPException(403)
tok = playback_token(lesson.playback_id, ttl_seconds=2 * 3600)
return {
"playbackId": lesson.playback_id,
"token": tok,
"streamUrl": STREAM.format(pid=lesson.playback_id, tok=tok),
"thumbnailUrl": THUMB.format(pid=lesson.playback_id, tok=tok),
"spritesheetUrl": SPRITE.format(pid=lesson.playback_id, tok=tok),
"expiresInSeconds": 2 * 3600,
}
Run it:
uvicorn app.main:app --reload
# INFO: Uvicorn running on http://127.0.0.1:8000
curl -s -b session=... http://127.0.0.1:8000/api/lessons/42/playback | python -m json.tool
{
"playbackId": "b331e0d8-bef4-4ad2-8760-757fdb2818b7",
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZjOWQ5MzY4...",
"streamUrl": "https://stream.fastpix.com/b331e0d8-...m3u8?token=eyJ...",
"thumbnailUrl": "https://images.fastpix.com/b331e0d8-.../thumbnail.jpg?token=eyJ...",
"spritesheetUrl": "https://images.fastpix.com/b331e0d8-.../spritesheet.jpg?token=eyJ...",
"expiresInSeconds": 7200
}
Notice there is no user ID inside the token. There doesn't need to be. The token is scoped to one playback ID and one time window; who it was issued to is your database's business, not the CDN's.
5. ▶️ Player side
The FastPix web player takes the token as an attribute, so the front-end is a fetch and a render:
<!-- templates/lesson.html -->
<script type="module">
const r = await fetch(`/api/lessons/${LESSON_ID}/playback`);
const { playbackId, token } = await r.json();
const p = document.createElement("fastpix-player");
p.setAttribute("playback-id", playbackId);
p.setAttribute("token", token);
p.setAttribute("stream-type", "on-demand");
document.querySelector("#stage").replaceChildren(p);
</script>
If you'd rather use hls.js or a native <video>, hand it streamUrl directly. The token is just a query parameter on the manifest URL.
For the poster frame, use thumbnailUrl from the same response. Do not build a thumbnail URL without the token "because it's only an image". That image is a frame of the paid content.
6. Expiry, rotation, and the retrofit trap
Expiry is the revocation mechanism. When a membership lapses, stop minting; the current token dies on its own. Pick a TTL that covers one sitting of your longest content and add a refresh call for the long tail. Before you commit to a TTL, run this test: mint a 120-second token, start playback, and watch what happens at 180 seconds. That tells you whether your provider validates on the manifest only or on every segment, and therefore whether your refresh has to be proactive.
Rotation is why kid is in every token. Suspect a leak? Create a second signing key, switch FASTPIX_SIGNING_KEY_ID and the PEM, then delete the old key through the signing-keys API. Tokens signed by the deleted key stop verifying. No flag day, no downtime.
The retrofit trap. If your library was public before, setting the media to private does not revoke public playback IDs that already exist; the FastPix docs call this out explicitly. List the playback IDs for each asset and delete the public ones:
curl -s -u "$FASTPIX_TOKEN_ID:$FASTPIX_SECRET" \
https://api.fastpix.com/v1/on-demand/$MEDIA_ID/playback-ids | python -m json.tool
# look for "accessPolicy": "public" entries, then DELETE each by id
Create a fresh private one if the asset has none:
curl -s -X POST https://api.fastpix.com/v1/on-demand/$MEDIA_ID/playback-ids \
-u "$FASTPIX_TOKEN_ID:$FASTPIX_SECRET" -H 'Content-Type: application/json' \
-d '{"accessPolicy": "private"}'
Optional belt-and-braces for embed abuse: per-playback-ID domain restrictions (up to 25 entries, wildcards like *.yourdomain.com) via PATCH /v1/on-demand/{mediaId}/playback-ids/{playbackId}/domains. It's not a substitute for tokens; it's a cheap extra fence.
7. ✅ The four-request launch test
PID=b331e0d8-bef4-4ad2-8760-757fdb2818b7
curl -s -o /dev/null -w "%{http_code}\n" "https://stream.fastpix.com/$PID.m3u8"
curl -s -o /dev/null -w "%{http_code}\n" "https://images.fastpix.com/$PID/thumbnail.jpg"
curl -s -o /dev/null -w "%{http_code}\n" "https://images.fastpix.com/$PID/spritesheet.jpg"
curl -s -o /dev/null -w "%{http_code}\n" "https://stream.fastpix.com/$PID.m3u8?token=$EXPIRED_TOKEN"
All four should come back as client errors (not 200). If the thumbnail line prints 200, you've shipped a public trailer for private content. Then one more: the token from step 4 on the stream URL should return 200 and a manifest.
Wrapping up
| Layer | What it stops | Where it lives |
|---|---|---|
accessPolicy: private |
Any URL without a token | On the asset |
Per-viewer RS256 JWT, short exp
|
Shared links outliving a session | Your backend |
| Token on thumbnail + spritesheet | Leaking frames via "just images" | Same token, image host |
kid + key rotation |
A leaked private key | Signing-keys API |
| Domain allow-list | Hot-linked embeds | Per playback ID |
What's next
- The FastPix token guide covers the claim set, the hosted signer for debugging, and custom-domain signed URLs if you serve from
media.yourdomain.com: https://fastpix.com/docs/video-security/generate-jwts-for-secure-media - Add a
/playback/refreshendpoint and have the player call it a minute beforeexpiresInSecondsruns out. - The same design works on Mux (signed playback IDs) and Cloudflare Stream (signed URLs); the claim names differ, the three layers don't.
Top comments (0)