An M3U8 Played Fine but Wouldn't Download: Fixing Our AES-128 Detection
While building the M3U8-to-MP4 converter and playlist inspector, we received a puzzling report. A URL played normally. The inspector said it was unencrypted. The converter refused to download it because it was encrypted.
Both tools needed work. The inspector had drawn a conclusion about the whole stream from the entry playlist alone, and the converter didn't yet support AES-128 decryption. Changing the error message would have left both problems in place.
The examples below use fictional addresses and filenames. They contain no user media URLs or keys. The test results come from our implementation and October 1, 2026 testing: controlled fixtures for IV handling, key rotation, transitions between encrypted and clear segments, and encrypted initialization sections, plus a complete user-provided stream converted in Chromium. We haven't tested every source, device, or encryption scheme.
The key was in the child playlist
Our first encryption check was too simple: if the file contained no EXT-X-KEY, report it as unencrypted. A master playlist can contain only links to quality variants, with no media segments or key declarations at all.
An entry playlist might look like this:
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=2091000,RESOLUTION=1920x1080
1080p/index.m3u8
The encryption declaration appears when you open 1080p/index.m3u8:
#EXTM3U
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:100
#EXT-X-KEY:METHOD=AES-128,URI="key.key",IV=0x00000000000000000000000000000000
#EXTINF:4.0,
segment-100.ts
#EXT-X-ENDLIST
The absence of a key declaration in the entry file told us nothing about that child. We changed the inspector to leave encryption unconfirmed for this kind of master playlist and ask the user to inspect a child playlist. Video variants and separate audio playlists can be opened individually. The inspector reports on the file you opened; it doesn't quietly download and scan every branch.
The distinction between master and media playlists is described in RFC 8216's protocol overview. Before trusting an encryption result, check which kind of playlist produced it.
Playback had already handled decryption
A player can fetch each segment, retrieve its key, decrypt the bytes, and feed the result into its playback pipeline. Most users never see a separate decryption step. A picture on screen doesn't tell you whether the downloaded segment started out as plaintext.
Our converter followed a separate path: fetch a complete on-demand stream, then remux it into MP4 in the browser. Its early implementation rejected encrypted streams to avoid feeding ciphertext to the media engine. Support in the player didn't give the converter the same capability.
We fixed the inspector's unknown state and added decryption before remuxing for supported AES-128 streams. Removing the encrypted-stream check alone would have passed invalid media bytes to the next stage.
Tracking the key and IV took more care than the cipher
We use the browser's native Web Crypto API for AES-CBC. We didn't implement our own cipher. MDN's documentation for SubtleCrypto.decrypt covers the API and its secure-context requirement.
The difficult part was assigning the right key and IV to each resource. A key declaration applies to subsequent segments until another declaration changes that state. Keeping only the last key found in a playlist would decrypt earlier segments with the wrong key after a rotation. Our parser saves the active encryption information on each segment. METHOD=NONE clears that state for the segments that follow.
The IV also needs to be handled per segment. We use an explicit IV when the playlist provides one. Otherwise, we derive the 16-byte value from the media sequence number. With EXT-X-MEDIA-SEQUENCE:100, the first segment uses sequence 100 and the next uses 101. The row number in the inspector isn't the media sequence number. The byte-order rule is specified in RFC 8216, Section 5.2.
The key response must contain the expected binary data. We require exactly 16 bytes, so a login page, an error JSON response, or a string of hexadecimal text won't be accepted as a key. Encrypted initialization sections have another requirement: an AES-128 EXT-X-MAP needs an explicit IV. We report a missing IV rather than inventing one.
A working player elsewhere doesn't prove the key is accessible here
The browser must be able to read the master playlist, child playlists, media segments, initialization sections, and keys. A cross-origin restriction on any of those requests can stop conversion. A player on the source website may use a different origin or authorization setup.
Pay particular attention to the key request. A 200 response can still contain a login page. Check the response type and byte length in the browser's network panel. Keep key contents and credential-bearing URLs out of public bug reports.
Our converter caches imported keys only within the current conversion. It doesn't write them to persistent storage or logs. Requests using the same key URI can reuse that imported key during the job, but it isn't kept for later conversions.
For a failing download, this is the order we'd check:
- Open the entry URL in the inspector. If it is a master playlist, inspect the selected video and audio children.
- Check
METHOD, the key URI, and the IV. Confirm that the stream uses the ordinary AES-128 format supported by the tool. - Inspect the key request for HTTP or CORS failures, and check that its response is binary data of the expected length.
- If the key is readable but decryption fails, check the IV, media sequence, key rotation boundaries, and whether the segment is complete.
- If decryption succeeds but MP4 processing fails, check codecs, missing segments, and changes in stream parameters.
What does decryption cost?
AES decryption runs through a native browser API and doesn't require re-encoding the video. The job also spends time downloading media, writing it into the engine's in-memory filesystem, and producing the MP4. The balance depends on the device and network; we don't have a fixed percentage to quote.
Memory deserves closer attention. Downloaded bytes, decrypted segments, engine data, and the output file can coexist. Our 256 MiB download budget is not a 256 MiB limit on browser memory. Long videos can still put pressure on devices with little available RAM. We cover that in the browser conversion and deployment notes.
The converter currently supports ordinary AES-128 with accessible keys in the supported identity format. SAMPLE-AES, DRM, and other key formats remain unsupported. It cannot reconstruct a missing key or bypass the source's authorization requirements.
Checking the resulting video
We first compared decryption against independently generated encrypted fixtures, checking that the plaintext bytes matched. Tests then covered implicit and explicit IVs, key rotation, and METHOD=NONE. Browser checks included encrypted TS and fMP4 with an encrypted initialization section, as well as invalid keys, invalid ciphertext, and cancellation.
The complete user-provided sample contained 412 segments. Its output was about 210.78 MiB. The browser read a resolution of 1920ร1080 and a duration of roughly 823.59 seconds, and it could seek close to the end. That confirmed download, decryption, and remuxing for that sample.
The inspector now distinguishes missing evidence from a known result, and the converter has an explicit decryption step. If your stream plays but won't download, trace the failure through playlist selection, resource requests, decryption, and remuxing. For failures involving resource paths or segment layout, see our notes on relative URLs, byte ranges, and initialization sections.