Search “how to protect course videos” and you get two kinds of answers. Consumer-facing posts say the link expires after a while, without explaining what mints it, what it’s bound to, or what happens when it does. Generic cloud-storage docs explain signed URLs in the abstract, for any file, with no mention of the actual scenario a course creator worries about: a paying student forwarding a lesson link to someone who never enrolled. Neither one gets you to working code.
This post is the code. We’ll import a batch of lectures into a per-course collection, write the server-side check that runs when an enrolled student requests a lesson, mint a short-lived signed playback token scoped to that request, and hand it to the player. Then we’ll be explicit about the one thing every version of this pattern shares: it’s access control, not copy protection.
Token-protected course video works by checking enrollment on your server, then asking your video provider for a playback URL that already carries a short-lived signed token — the player never sees a raw, permanent link. This stops casual link-sharing and hotlinking. It does not stop someone who is authorized to watch a lesson from screen-recording it; that requires DRM (Widevine/FairPlay), a different and heavier system than signed tokens.
Step 1: import lectures into a per-course collection
Start with the catalog, not the player. Each course is a collection; each lecture is a video imported into it. If your lecture files already live in object storage — S3, R2, wherever your upload pipeline dropped them — rehelios can pull them in server-side, without routing gigabytes through your own backend first:
npx @rehelios/cli import https://storage.example.com/lectures/module-03-lesson-02.mp4 \
--collection col_intro_to_react \
--wait \
--json
That call transcodes the lecture to adaptive HLS, generates captions and chapters, and stores it behind the CDN — encoding is free, so importing an entire course catalog costs nothing beyond storage and delivery. The --collection flag is what makes the rest of this pattern work: every lesson in “Intro to React” lands under the same col_intro_to_react id, so your backend can reason about “this student’s access to this course” as one thing instead of tracking permissions per video. Run the same import in a loop over your existing catalog and you’re done with onboarding — no re-upload, no player changes yet.
Step 2: check enrollment, then ask for a playback URL
This is the part generic docs skip. The video provider doesn’t know who’s enrolled in what — your application does. So the token has to be minted on your server, after your own enrollment check, never handed out client-side:
async function getLessonPlaybackUrl(req: { userId: string; lessonId: string }) {
const lesson = await db.lessons.findById(req.lessonId);
const enrolled = await db.enrollments.exists({
userId: req.userId,
courseId: lesson.courseId,
});
if (!enrolled) throw new ForbiddenError("not enrolled in this course");
const res = await fetch(`https://api.rehelios.com/v1/videos/${lesson.videoId}`, {
headers: { Authorization: `Bearer ${process.env.REHELIOS_API_KEY}` },
});
const { data } = await res.json();
// data.playbackUrl already carries a short-lived signed token scoped to
// this request. Mint it fresh per lesson view — never cache or store it.
return { playbackUrl: data.playbackUrl };
}
Nothing here mints or stores a token itself. Your server does the one thing only it can do — verify enrollment — and then asks rehelios for the video. GET /v1/videos/{id} hands back metadata including a playbackUrl that’s already gated at the edge, scoped to this request. Because the request happens on every lesson view rather than once at login, revoking access is immediate: cancel the enrollment row and the next request for that lesson simply fails the check before a token is ever minted.
Step 3: hand the token to the player
The frontend never talks to the video provider directly and never sees a permanent URL — it calls your backend, gets a playbackUrl, and plays it:
const { playbackUrl } = await fetch(`/api/lessons/${lessonId}/play`).then((r) => r.json());
player.src = playbackUrl;
Keep the token’s lifetime short and refresh it before it expires rather than after playback stalls — a course platform where students binge through a dozen short modules back-to-back will hit that boundary constantly if the TTL is too generous or renewal is reactive instead of proactive. We cover the manifest-vs-segment mechanics and the TTL tradeoff in more depth in how signed playback tokens actually work for HLS and DASH — worth reading before you pick a lifetime for your own tokens.
Optional: a domain allowlist as a second layer
Per-viewer tokens are the right default for gated lesson content, but they add a request to every lesson view. If a course is embedded only on pages you control — your own app’s course viewer, not a public share page — a per-org domain allowlist is a simpler, cheaper layer to add on top: the CDN edge checks the referring origin instead of validating a token, so an embed on app.yourcourseplatform.com plays and an embed anywhere else doesn’t, no per-request minting involved. It doesn’t replace enrollment-checked tokens for content you actually want gated per-student — it’s a second layer that closes off “someone copies the iframe embed code onto a random site,” which a signed token alone doesn’t prevent if the token itself gets copied along with it.
What this does not stop
Signed playback tokens are access control, not DRM. They stop someone from forwarding a raw video link that keeps working forever, from hotlinking a lesson into another site, and from scraping predictable segment URLs off a public bucket. They do not stop a student who is legitimately watching a lesson from opening a screen recorder and capturing it — no signed-URL scheme does, regardless of what the marketing copy around it implies.
A signed token proves the request is allowed. It says nothing about what happens after the pixels reach a screen.
If your requirement is genuinely “must survive an authenticated viewer trying to rip the video,” that’s Widevine/FairPlay-grade DRM — hardware-backed decryption keys, license servers, output protection — a different and much heavier system than anything covered here, and out of scope for this post. Most course platforms don’t actually need that tier: the real threat model is casual sharing — a student posting a lesson link in a Discord server, or a lapsed subscriber still holding a bookmark — and enrollment-checked signed tokens close that gap completely. Be precise with your own users about which one you’re offering; “we protect your content” reads very differently once someone knows what it does and doesn’t cover.
Where this leaves you
The pattern end to end: import lectures into a per-course collection so access maps cleanly to a course id, check enrollment on your server before every lesson view, ask for a playbackUrl instead of storing one, and hand it straight to the player. Add a domain allowlist if embeds need a second layer. None of it requires running your own token-issuing service or parsing HLS manifests by hand — the enrollment check is the only part that’s actually your application’s job. For the fuller product picture of how this fits a course platform end to end, including captions, chapters, and per-GB pricing that doesn’t punish a growing catalog, see protecting course video. And if you want to see this exact migration pattern run against a real course catalog, LearnBase’s move to rehelios covers the production version of steps one and two above, at scale.