A plain MP4, delivered as AES-128 encrypted HLS by nginx-vod-module
The MP4 on the server is not encrypted. For each player request, Kaltura's nginx-vod-module makes the HLS manifest, cuts the segment from the MP4, and encrypts it with AES-128. The Kaltura Player gets the key from a protected URL and decrypts the segments in the browser.
/key and decrypts each segment.The player on this server
This is the Kaltura Player page at /player. It plays /hls/video.mp4/index.m3u8, which nginx-vod-module makes from the MP4 when the player asks for it. The log under the player shows playing when the decryption works.
Where the setup is in the code
All the files are in the repository. The line numbers are the lines of the files that run on this server. The highlighted lines do the work.
| # | File and lines | What it does |
|---|---|---|
| 1 | nginx-vod/Dockerfile:14–25 | Downloads nginx-vod-module 1.33 and compiles it into nginx 1.28.3 with OpenSSL. |
| 2 | nginx-vod/nginx.conf.template:31–53 | The /hls/ location. The module makes the manifest and encrypts each segment with AES-128. |
| 3 | nginx-vod/nginx.conf.template:55–62 | The /key location. nginx gives the key only to a request with the token. |
| 4 | nginx-vod/docker-entrypoint.sh:4–13 | Makes a random key seed when the container starts and writes it into the nginx configuration. |
| 5 | public/index.html:36–59 | The Kaltura Player setup. The player sends the token only on the key request. |
| 6 | nginx-vod/Dockerfile:36–37 | Puts the sample MP4 into the image. The file is not encrypted. |
1Compile nginx-vod-module into nginx
nginx-vod-module is a normal nginx module. The build downloads the release tag and adds it with --add-module. The module uses OpenSSL for the AES encryption, so --with-http_ssl_module is necessary.
14RUN curl -fsSL https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz | tar xz \15 && curl -fsSL https://github.com/kaltura/nginx-vod-module/archive/refs/tags/${VOD_VERSION}.tar.gz | tar xz16# OpenSSL (--with-http_ssl_module) is necessary for the vod encryption.17# The sub module changes the default manifest URL in the page.18RUN cd nginx-${NGINX_VERSION} \19 && ./configure --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx \20 --conf-path=/etc/nginx/nginx.conf --pid-path=/run/nginx.pid \21 --error-log-path=/dev/stderr --http-log-path=/dev/stdout \22 --with-http_ssl_module --with-http_sub_module --with-threads --with-file-aio \23 --add-module=/src/nginx-vod-module-${VOD_VERSION} \24 --with-cc-opt="-O3" \25 && make -j"$(nproc)" && make install
2Make the manifest and encrypt the segments
These directives are the full encryption setup. There is no encryption step before storage and no encrypted file on disk.
31 # nginx-vod-module makes the manifest and the encrypted segments from the MP4 files in /media.32 # Example: /hls/video.mp4/index.m3u8 for /media/video.mp433 location /hls/ {34 # The key is available only through /key. $request_uri is the URL35 # of the client request, so the internal rewrite from /key passes.36 if ($request_uri ~ "/encryption\.key") {37 return 403;38 }3940 vod hls;41 alias /media/;42 vod_mode local;43 vod_segment_duration 4000;44 vod_align_segments_to_key_frames on;45 # Relative segment URLs. Behind a TLS proxy, absolute URLs get http:// and the browser blocks them.46 vod_base_url "";4748 vod_secret_key "${KEY_SECRET}";49 vod_hls_encryption_method aes-128;50 vod_hls_encryption_key_uri "/key";5152 add_header Cache-Control no-store always;53 }
| Line | Directive | Effect |
|---|---|---|
| 40 | vod hls | The module answers this location with HLS: index.m3u8, seg-N-v1-a1.ts, and encryption.key. |
| 42 | vod_mode local | The module reads the MP4 from the local disk. Kaltura production uses mapped, where an API returns the file path. |
| 48 | vod_secret_key | The seed of the key. The key is MD5(seed). Location 4 makes the seed. |
| 49 | vod_hls_encryption_method aes-128 | Encrypts each segment with AES-128-CBC and PKCS#7 padding. The manifest gets METHOD=AES-128. |
| 50 | vod_hls_encryption_key_uri | Changes the key URL in the manifest from encryption.key to /key, where nginx checks the token. |
| 36–38 | if … return 403 | Refuses a direct request for encryption.key. The key is available only through /key. |
3Give the key only with the token
nginx checks the Authorization header. With the correct token, an internal rewrite sends the request to the module, and the module returns the 16-byte key. Without the token, the answer is 403.
55 # Same contract as server.py: the key only with "Authorization: Bearer <KEY_TOKEN>".56 # The secret does not include the file path, so all files use the same key.57 location = /key {58 if ($http_authorization != "Bearer ${KEY_TOKEN}") {59 return 403;60 }61 rewrite ^ /hls/${VIDEO_FILE}/encryption.key last;62 }
4Make the key seed when the container starts
The seed is random for each container, so the key is never in an image layer. envsubst writes the seed and the token into the configuration of lines 48 and 58.
4# Make the key secret once per container. The AES-128 key is the MD5 of this secret.5if [ ! -f /etc/nginx/nginx.conf.generated ]; then6 KEY_SECRET=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')7 export KEY_SECRET8 envsubst '${KEY_SECRET} ${KEY_TOKEN} ${VIDEO_FILE}' < /app/nginx.conf.template > /etc/nginx/nginx.conf9 touch /etc/nginx/nginx.conf.generated10fi1112echo "Open http://127.0.0.1:8766/ (video: /media/${VIDEO_FILE}, key token: ${KEY_TOKEN})"13exec nginx -g 'daemon off;'
5Send the token from the Kaltura Player
The player uses hls.js. xhrSetup adds the token only to the /key request. The manifest and the segments go without credentials.
36 // The key request is the only request that gets the token.37 let keyToken = document.getElementById('token').value;3839 const player = KalturaPlayer.setup({40 targetId: 'player',41 // Direct source playback: no Kaltura partner is needed.42 // For a Kaltura-hosted entry, set partnerId (and ks) and call player.loadMedia({ entryId }).43 provider: { partnerId: -1 },44 playback: {45 preload: 'auto', // load manifest, key, and first segment before play46 preferNative: { hls: false }, // use hls.js also in Safari, so xhrSetup runs47 options: {48 html5: {49 hls: {50 xhrSetup: (xhr, url) => {51 if (new URL(url, location.href).pathname === '/key') {52 xhr.setRequestHeader('Authorization', 'Bearer ' + keyToken);53 }54 }55 }56 }57 }58 }59 });
6Store the plain MP4
The image contains the sample MP4 as a normal file. A mount at /media replaces it with other MP4 files.
36# The sample video. A mount at /media replaces it with your MP4 files.37COPY media/video.mp4 /media/video.mp4
Test this server now
Your browser sends these requests to this server. Then it decrypts segment 1 with the Web Crypto API (AES-CBC). The IV is the segment sequence number, which is the HLS default when the manifest has no IV attribute. An MPEG-TS stream is a chain of 188-byte packets, and each packet starts with the sync byte 0x47.
As served encrypted
…
- Size
- …
- Packets with 0x47
- …
After decryption clear MPEG-TS
…
- Size
- …
- Packets with 0x47
- …
Do the test with curl
B=https://kaltura-aes.jrobe.cloud
curl -s $B/hls/video.mp4/index.m3u8
curl -s -o /dev/null -w '%{http_code}\n' $B/key
curl -s -H 'Authorization: Bearer demo-token' -o key.bin $B/key
curl -s -o seg.ts $B/hls/video.mp4/seg-1-v1-a1.ts
openssl aes-128-cbc -d -K "$(xxd -p key.bin)" \
-iv 00000000000000000000000000000001 -in seg.ts -out clear.ts
ffprobe clear.ts
What changed in the code for this deployment
The base is commit 5b9124e, where the setup ran only on a local Docker container. These are all the code changes for this server. The encryption directives did not change.
nginx-vod/nginx.conf.template
Relative segment URLs (lines 45–46). The module wrote full segment URLs with the scheme of the request. The HTTPS proxy of the server sends plain HTTP to the container, so the URLs started with http://, and the browser blocked them on this HTTPS page. vod_base_url "" makes the URLs relative.
This page at /, the player at /player (lines 15–29).
@@ -12,10 +12,17 @@ http { server { listen 8766; - # The Kaltura Player page. The default manifest URL changes to the vod manifest.+ # The proof page: the player, the code locations, and a live test. location = / { root /app/public; try_files /index.html =404;+ add_header Cache-Control no-store always;+ }++ # The Kaltura Player page. The default manifest URL changes to the vod manifest.+ location = /player {+ root /app/public;+ try_files /player.html =404; sub_filter 'value="/media/index.m3u8"' 'value="/hls/${VIDEO_FILE}/index.m3u8"'; sub_filter_once on; add_header Cache-Control no-store always;@@ -35,6 +42,8 @@ http { vod_mode local; vod_segment_duration 4000; vod_align_segments_to_key_frames on;+ # Relative segment URLs. Behind a TLS proxy, absolute URLs get http:// and the browser blocks them.+ vod_base_url ""; vod_secret_key "${KEY_SECRET}"; vod_hls_encryption_method aes-128;
nginx-vod/Dockerfile
The image contains the sample MP4, the player page as player.html, and this page as index.html. Before, the MP4 came only from a mount.
@@ -30,10 +30,12 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY --from=build /usr/sbin/nginx /usr/sbin/nginx COPY --from=build /etc/nginx /etc/nginx-COPY public/index.html /app/public/index.html+COPY public/index.html /app/public/player.html+COPY docs/index.html /app/public/index.html COPY nginx-vod/nginx.conf.template nginx-vod/docker-entrypoint.sh /app/+# The sample video. A mount at /media replaces it with your MP4 files.+COPY media/video.mp4 /media/video.mp4 -# Mount the directory with your MP4 files at /media. VOLUME /media ENV KEY_TOKEN=demo-token \ VIDEO_FILE=video.mp4
nginx-vod/docker-compose.yml new file
The deployment platform builds the image from this file. It has one service.
@@ -0,0 +1,13 @@+# One service: nginx with nginx-vod-module serves media/video.mp4 as AES-128 HLS.+name: kaltura-vod++services:+ vod:+ build:+ context: ..+ dockerfile: nginx-vod/Dockerfile+ environment:+ KEY_TOKEN: ${KEY_TOKEN:-demo-token}+ VIDEO_FILE: ${VIDEO_FILE:-video.mp4}+ ports:+ - "${VOD_PORT:-127.0.0.1:8766}:8766"
.dockerignore and .gitignore
Both files ignored all of media/. Now they let media/video.mp4 through, so the sample MP4 goes into the build.
@@ -5,4 +5,5 @@ public/media/ .DS_Store **/.git saas/.env-media/+media/*+!media/video.mp4
@@ -11,6 +11,7 @@ vendor/ # SaaS simulation secrets saas/.env -# Your MP4 files for nginx-vod/+# Your MP4 files for nginx-vod/. The image contains media/video.mp4 as the sample. media/* !media/.gitkeep+!media/video.mp4
The same encryption in a Kaltura-style delivery chain
The repository also has a second setup, saas/. It uses the module's own Kaltura configuration templates and two more Kaltura modules. The encryption is the same, and the key protection moves to CDN tokens.
Packager
vod_mode mapped: an API returns the MP4 path of each flavor.vod_secret_key "<secret>$vod_filepath": each flavor gets its own key.nginx-secure-token-moduleadds an Akamai token to theEXT-X-KEYURI only.
API and CDN
playManifestchecks the KS and thesviewprivilege, then redirects to the CDN with a token.- The CDN checks the token on manifests and keys.
- Segments have no token, so the CDN caches them for all viewers.
AES-128 is not DRM
The browser receives the key in clear form, so a skilled user can decrypt the video. The token only controls who gets the key. This page shows the token (demo-token) so that the test works. For strong protection, the same module supports sample-aes (FairPlay) and CENC (Widevine, PlayReady) with a DRM key server through vod_drm_enabled.