DEV Community

Mason K
Mason K

Posted on

Lock down HLS playback with JWT signed URLs (Express 5 + hls.js)

TL;DR

Anyone who copies your .m3u8 URL can watch your paywalled video from anywhere, forever. We're fixing that with an Express 5 gateway that mints short-lived JWTs, validates them on the manifest AND every segment, and rewrites playlists so the token travels automatically. Plus the bug everyone ships first: tokens that expire mid-movie.

📦 Code: github.com/USER/hls-signed-playback (replace before publishing)

The problem in one curl

Your app has a login wall. Your video does not:

curl -s https://cdn.example.com/course/lesson1/index.m3u8 | head -5
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXTINF:6.0,
seg_000.ts
Enter fullscreen mode Exit fullscreen mode

No cookie, no session, full response. Paste that URL into VLC and the whole lesson plays. HLS is a master playlist pointing at segment files, hundreds of separate HTTP requests, and if none of them check authorization, your paywall only protects the HTML around the player.

Let's fix it end to end. You need node 24.x and ffmpeg (any recent build; 7.x or 8.x both fine) to generate test content.

1. Set up the project

mkdir hls-signed-playback && cd hls-signed-playback
npm init -y && npm i express jsonwebtoken
mkdir media public
Enter fullscreen mode Exit fullscreen mode

Generate an HLS rendition of any mp4 you have lying around:

ffmpeg -i sample.mp4 -c:v libx264 -c:a aac \
  -hls_time 6 -hls_playlist_type vod \
  -hls_segment_filename "media/seg_%03d.ts" \
  media/index.m3u8
Enter fullscreen mode Exit fullscreen mode
ls media
index.m3u8  seg_000.ts  seg_001.ts  seg_002.ts  ...
Enter fullscreen mode Exit fullscreen mode

2. Mint short-lived playback tokens

// server.js
import express from "express";
import jwt from "jsonwebtoken";
import fs from "node:fs";
import path from "node:path";

const app = express();
const SECRET = process.env.PLAYBACK_SECRET ?? "dev-only-secret";
const MEDIA_DIR = path.resolve("media");

// In real life this sits behind your session auth.
app.post("/api/playback-token/:videoId", (req, res) => {
  const token = jwt.sign(
    { sub: "user-123", vid: req.params.videoId },
    SECRET,
    { expiresIn: "15m" }
  );
  res.json({ token });
});
Enter fullscreen mode Exit fullscreen mode

The claims matter: vid pins the token to one video, sub ties it to a user (you'll want that later for revocation and concurrency caps), exp comes free from expiresIn.

3. Validate the manifest, and rewrite it

Here's the core trick. The player only requests URLs it was given. So when we serve the manifest, we append the token to every segment URI, and segment auth happens automatically:

// server.js (continued)
function verify(req, res, next) {
  try {
    const payload = jwt.verify(req.query.token, SECRET, { clockTolerance: 60 });
    if (payload.vid !== req.params.videoId) throw new Error("wrong video");
    req.token = req.query.token;
    next();
  } catch {
    res.status(403).json({ error: "invalid or expired token" });
  }
}

app.get("/video/:videoId/index.m3u8", verify, (req, res) => {
  const raw = fs.readFileSync(path.join(MEDIA_DIR, "index.m3u8"), "utf8");
  const signed = raw
    .split("\n")
    .map(line =>
      line && !line.startsWith("#")
        ? `${line}?token=${req.token}`
        : line
    )
    .join("\n");
  res.type("application/vnd.apple.mpegurl").send(signed);
});

app.get("/video/:videoId/:file", verify, (req, res) => {
  res.sendFile(path.join(MEDIA_DIR, path.basename(req.params.file)));
});

app.use(express.static("public"));
app.listen(3000, () => console.log("http://localhost:3000"));
Enter fullscreen mode Exit fullscreen mode

💡 Tip: clockTolerance: 60 forgives a minute of client/server clock skew. Strict exp checks plus one device with a fast clock equals failures you will never reproduce locally.

Non-comment lines in an m3u8 are URIs, so the rewrite is a two-line map. For multi-rendition streams you'd apply the same rewrite to the master playlist's variant URIs too.

4. Wire up the player

<!-- public/index.html -->
<video id="video" controls></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
async function play() {
  const { token } = await (await fetch("/api/playback-token/lesson1", {
    method: "POST"
  })).json();

  const video = document.getElementById("video");
  const src = `/video/lesson1/index.m3u8?token=${token}`;

  if (Hls.isSupported()) {
    const hls = new Hls();
    hls.loadSource(src);
    hls.attachMedia(video);
  } else {
    video.src = src; // Safari plays HLS natively
  }
}
play();
</script>
Enter fullscreen mode Exit fullscreen mode

Because the token rides in the URL and we rewrote the playlist, hls.js (v1.7 as of this writing) needs zero special config. If you'd rather use an Authorization header or cookies instead of query strings, hls.js has you covered with xhrSetup:

const hls = new Hls({
  xhrSetup: (xhr) => {
    xhr.withCredentials = true; // cookie mode
    // or: xhr.setRequestHeader("Authorization", `Bearer ${token}`);
  }
});
Enter fullscreen mode Exit fullscreen mode

Run it and confirm both doors are locked:

node server.js
curl -i http://localhost:3000/video/lesson1/index.m3u8
HTTP/1.1 403 Forbidden

curl -i "http://localhost:3000/video/lesson1/seg_000.ts"
HTTP/1.1 403 Forbidden
Enter fullscreen mode Exit fullscreen mode

The copied-link attack is dead: a shared URL stops working when its token expires.

5. The bug you just shipped: expiry at minute 41 ⏰

Set expiresIn: "30s" temporarily and keep watching past the 30 second mark. Around the next segment request:

GET /video/lesson1/seg_007.ts?token=eyJhb... 403
Enter fullscreen mode Exit fullscreen mode

hls.js retries, then surfaces a fatal network error and playback dies. In production this is the two-hour lecture that always fails around minute 41, only for your most engaged viewers, and never in your five-minute tests.

Two sane fixes:

  1. VOD: outlive the content. The token's job is to gate the start of playback and kill shared links, so set the TTL longer than your longest video (say, duration plus an hour). A 15 minute TTL on a 2 hour film adds support tickets, not security.
  2. Live or long sessions: slide the credential. Use cookie mode plus a refresh endpoint the page calls periodically, or re-issue the token when the player refreshes the playlist (live players re-fetch manifests every few seconds anyway).

6. Production notes

  • Move verification to the edge. The Express gateway is perfect for understanding the pattern, but at scale you don't want segment traffic through your app servers. The same JWT check runs in a CloudFront Function or a Cloudflare Worker, and CloudFront's signed cookies are a managed version of cookie mode.
  • Mind the CDN cache key. Query-string tokens mean your CDN must either strip the query from the cache key (so all users share cached segments while your edge still validates) or you'll get zero cache hits. Decide which layer validates, then configure caching deliberately.
  • This is anti-sharing, not DRM. Signed URLs kill link-sharing and hotlinking. They don't stop screen recording. If a content contract requires Widevine/FairPlay, that's a different (and much heavier) project.

7. The hardening checklist

Before this pattern faces the internet:

  • Query tokens leak into logs. ?token= ends up in CDN access logs, proxy logs, and sometimes Referer headers. Short TTLs bound the damage, cookie mode avoids it entirely, and either way, scrub query strings from logs you retain.
  • Set the cookie flags. Cookie mode wants HttpOnly, Secure, and SameSite=Lax (or SameSite=None; Secure if your CDN lives on another origin).
  • HS256 is fine for one service. The moment a CDN worker and your app server both verify tokens, switch to RS256 so the edge only ever holds a public key.
  • Pin claims deliberately. vid pins content and is cheap. IP pinning sounds tempting but mobile networks rotate addresses; strict IP checks break every commuter on a train.
  • Rotate the secret. Short-lived tokens make secret rotation nearly painless, which is itself a reason to keep TTLs sane.

What's next

Two natural extensions: cap concurrent sessions per sub claim (you now have the data to do it), and per-user watermarking keyed off the same token. If you're on a managed video platform, check its docs before building any of this; most ship a signed-playback feature that is exactly this pattern with the edge part done for you.

Top comments (0)