Securing HLS video streaming with server-side manifests and AES-128 encryption
I thought HLS streaming was secure because the URLs were temporary. Then I looked at the page source.
The orbsen-vid player was generating HLS streams using Azure Blob Storage. The flow was: transcode to HLS segments, upload to blob, generate a time-limited SAS URL, hand it to the browser. Time-limited means secure, right?
Not quite.
The problem with SAS tokens in the browser
A SAS URL looks like this:
https://yourstorage.blob.core.windows.net/container/hls/video-guid/master.m3u8?sv=2023-01-03&se=...&sig=...
That entire URL, including the signature, was being written into the JavaScript on the page. Anyone who opened DevTools and looked at the page source could copy it out, share it, download the raw HLS segments directly from Azure, and reassemble the video. The SAS tokens were set to expire in four hours, but four hours is more than enough time to download a full course.
The fix was not to put the URL there in the first place.
Server-side manifest proxying
The solution is to route all HLS playlist requests through the application server rather than handing the browser a direct blob URL.
The player now receives a proxy URL like:
/Player/{video-guid}/hls/master.m3u8
The server fetches the master playlist from blob storage, rewrites the rendition URLs to point back through the server, and returns the rewritten playlist. The rendition playlists do the same: the server fetches them from blob, rewrites each segment URL to include a fresh four-hour SAS token, and returns that to the browser.
The browser sees only internal application URLs for playlists. The SAS tokens appear in the segment URLs, but only inside the playlist that was fetched a moment ago. They never appear in page source. The player fetches segments directly from Azure Blob (no server bandwidth for that), but by then the token is embedded in a just-fetched playlist rather than baked into the HTML.
This also fixed a Safari bug for free. Safari's native HLS player resolves relative URLs from the base of the playlist it fetched. When the playlist came directly from blob storage, relative segment paths broke. Now the playlist is served from the app domain, relative resolution works correctly, and the separate xhrSetup hack in hls.js was removed.
AES-128 segment encryption
Proxying the manifests means the playlist itself is protected, but the segments are still unencrypted blobs. Someone with the segment URL can still download plaintext video. The four-hour SAS window helps, but it is not a strong guarantee.
The next layer is AES-128 HLS encryption.
When a video is transcoded, the service now generates a unique random 16-byte key for that video. ffmpeg receives a key info file pointing at that key, and encrypts every .ts segment with it. The encrypted segments are uploaded to blob storage as normal. The key is not.
The key is stored in the application database only. It never touches blob storage.
The HLS spec handles the rest. ffmpeg embeds a line in each rendition playlist:
#EXT-X-KEY:METHOD=AES-128,URI="https://video.example.com/Player/{guid}/hls/key"
When the player encounters that line, it fetches the key from the application server before it can decrypt any segment. That endpoint is gated: the creator must have an active subscription. If they do not, the server returns 404 and the player cannot decrypt the segments regardless of whether it has the segment URLs.
The result: even if someone extracts the segment URLs from a playlist and downloads every .ts file, they have encrypted ciphertext. The segments are useless without the key, and the key is only served through an authenticated endpoint.
What this looks like end to end
A viewer loads the player:
- The player requests
/Player/{guid}/hls/master.m3u8from the app server. - The server checks the subscription gate, fetches the playlist from blob, rewrites the rendition URLs, returns it.
- The player requests
/Player/{guid}/hls/v2/playlist.m3u8. - The server rewrites segment URLs with fresh four-hour SAS tokens and returns the playlist.
- The player reads the
#EXT-X-KEYline and requestshttps://video.example.com/Player/{guid}/hls/key. - The server checks the subscription gate again and returns the 16-byte AES key.
- The player fetches segments directly from Azure Blob and decrypts them in memory using the key.
Each layer closes a different gap. The manifest proxy removes credentials from page source. The SAS tokens on segment URLs limit the window in which a leaked URL is valid. The AES encryption makes the segments themselves worthless without a separate server-side request that is independently gated.
A note on existing videos
All of this is backwards compatible. Videos transcoded before today have no #EXT-X-KEY in their playlists. They play fine. They are being re-transcoded in the background tonight to pick up the encryption. When the worker finishes, every video on the platform will have its own unique key.
The implementation
The whole thing runs in ASP.NET Core on Hetzner, with Azure Blob for storage and Azure SQL for the database. The manifest proxy endpoints are minimal route handlers in Program.cs. The key generation is a single RandomNumberGenerator.GetBytes(16) call in TranscodeService.cs. The key endpoint is six lines.
The xhrSetup hook in hls.js that was previously needed to append SAS tokens to every XHR request is gone entirely. The code is simpler than what it replaced.
— Mícheál.