DEV Community

Mason K
Mason K

Posted on

Build a members-only video library in Python: private media, RS256 playback tokens, and tokenized thumbnails

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
Enter fullscreen mode Exit fullscreen mode
# .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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
{
  "success": true,
  "data": {
    "id": "fc9d9368-6ee5-4b16-ae50-880a2374bdc4",
    "privateKey": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2...",
    "createdAt": "2026-10-05T09:14:06.618993Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

💡 Tip: treat the webhook as idempotent. Processing-pipeline events can arrive more than once; mark_ready should 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,
    }
Enter fullscreen mode Exit fullscreen mode

Run it:

uvicorn app.main:app --reload
# INFO:     Uvicorn running on http://127.0.0.1:8000
Enter fullscreen mode Exit fullscreen mode
curl -s -b session=... http://127.0.0.1:8000/api/lessons/42/playback | python -m json.tool
Enter fullscreen mode Exit fullscreen mode
{
  "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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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/refresh endpoint and have the player call it a minute before expiresInSeconds runs 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)