Encrypted Video (AES-128)
Encrypted Video (AES-128)
Private videos protect the URL with a signed JWT — the segments themselves are still plain video. Encrypted videos protect the bytes: every media segment is AES-128 encrypted, and the decryption key is only handed out to a player that holds a signed, server-minted playback session.
Anyone can download an encrypted segment. Without the key it is noise.
Caution
Encryption is opt-in at upload time and cannot be turned on later. A video uploaded without encryptSegments has to be re-uploaded to become encrypted.
Step 1: Upload with encryptSegments
Add encryptSegments: true to either upload endpoint.
curl -X POST "https://api.antcdn.net/v1/videos/upload-url" \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "fileName": "demo.mp4", "allowPublicAccess": false, "encryptSegments": true }'| Field | Type | Default | Description |
|---|---|---|---|
encryptSegments | boolean | false | Produce an AES-128 encrypted rendition set and require a signed playback session for delivery |
Requires the Growth plan or higher. On a lower plan the request fails with 402 segment encryption requires Growth plan.
Works on:
POST /v1/videos/upload-url(pre-signed upload)POST /v1/videos/upload(upload from an accessible URL)
Not available on the legacy POST /v1/videos/process endpoint.
What AntCDN does with it
- Generates a random 16-byte AES-128 content key, unique to that video.
- Packages an encrypted rendition set alongside the clear one.
- Wraps the content key with a platform key-encryption key (AES-256-GCM) and stores only the ciphertext. The plaintext key is never at rest anywhere; the ephemeral copy used during packaging is deleted once the wrap succeeds.
- Marks every Edge Key for that video as requiring a signed playback session.
From then on, every CDN request for that video — master playlist, variant playlists, segments, captions, and the key itself — returns 401 unless it carries session headers.
Step 2: Mint a playback session (server-side)
Call this from your backend immediately before serving your player page. It needs your API key, so it must never run in the browser.
POST https://worker.antcdn.net/v1/playback/initX-Api-Key: {YOUR_ANTCDN_API_KEY}Content-Type: application/json
{ "edgeId": "edge_xxx" }Response:
{ "sessionId": "8a0fc075-fe05-451f-aae6-83a696bee637", "challengeId": "3c9ac0ba-6f11-4a0e-9a71-1d6ab6a1f0c2", "sessionSecret": "<64-char hex>", "videoId": "vid_abc123", "expiresAt": 1782243057000, "playlistUrl": "https://worker.antcdn.net/v1/edge_xxx/master.m3u8"}| Field | Use |
|---|---|
sessionId | Sent as X-Ant-Session on every request |
sessionSecret | HMAC key used to sign each request. Treat it like a password |
videoId | Your asset ID. Goes into every signature — returned here so you never have to look it up |
challengeId | Sent as X-Ant-Challenge-Id on the key request only |
expiresAt | Unix milliseconds. Sessions live 24 hours |
playlistUrl | Where to point the player |
Rules:
- One session per viewer, per playback start. Never share or reuse a session across users.
- Your API key is scoped: a dev key cannot mint sessions for prod assets, and a key from one organization cannot mint sessions for another’s (
403). - The endpoint returns
400for an Edge Key that is not on an encrypted video — there is no session to create.
Step 3: Sign every CDN request
Attach three headers to every request the player makes for this video:
X-Ant-Session: {sessionId}X-Ant-Timestamp: {unix seconds, recomputed per request}X-Ant-Signature: {lowercase hex HMAC-SHA256}Message — UTF-8, colon-delimited, no trailing newline:
{sessionId}:{videoId}:{timestamp}:{pathname}videoId— the asset ID from/v1/playback/init. Not your own internal ID for the video.timestamp— Unix seconds as a decimal string, identical to theX-Ant-Timestampheader. The edge allows ±30 seconds of drift.pathname— path only. No origin, no query string. e.g./v1/edge_xxx/master.m3u8.
HMAC key — hex-decode sessionSecret into 32 raw bytes. Signing with the UTF-8 bytes of the hex string is the single most common integration bug and produces 403 Invalid signature.
function hexToBytes(hex) { const b = new Uint8Array(hex.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return b;}
const key = await crypto.subtle.importKey( 'raw', hexToBytes(sessionSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
async function sign(message) { const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, '0')).join('');}Example message:
8a0fc075-fe05-451f-aae6-83a696bee637:vid_abc123:1782243057:/v1/edge_xxx/master.m3u8Step 4: The key request and its challenge
The variant playlist AntCDN serves you contains a rewritten key tag:
#EXT-X-KEY:METHOD=AES-128,URI="https://worker.antcdn.net/v1/edge_xxx/aes-key",IV=0x...Your player fetches that URI like any other request — with the three signature headers — plus:
X-Ant-Challenge-Id: {challengeId}The response is the raw 16 bytes of the AES-128 key (application/octet-stream, no-store).
Caution
The challenge is single-use. The first key fetch for a session succeeds; every later one returns 409 Challenge already consumed, even from the same player. A session that has spent its challenge cannot get the key again — you must mint a new session.
This is what stops a leaked session from becoming an unlimited key oracle. It also means your integration has to handle key re-fetches, which happen more often than you’d expect: a page reload, a player re-init, a user re-opening a modal. Two workable designs:
- Mint per playback start. Call
/v1/playback/initfresh every time the player mounts, and hand the result straight to the player. Simplest, and correct. - Give the player a refresh endpoint. Expose an endpoint on your own backend that proxies
/v1/playback/init, and have the player call it again on409or401. Required if playback can outlive a single mount.
Never cache a session in localStorage or any storage the user can read, and never keep it past expiresAt.
Player integration
HLS.js
const hls = new Hls({ xhrSetup: async (xhr, url) => { const { pathname } = new URL(url); const timestamp = Math.floor(Date.now() / 1000); const signature = await sign(`${sessionId}:${videoId}:${timestamp}:${pathname}`);
xhr.setRequestHeader('X-Ant-Session', sessionId); xhr.setRequestHeader('X-Ant-Timestamp', String(timestamp)); xhr.setRequestHeader('X-Ant-Signature', signature); if (pathname.endsWith('/aes-key')) { xhr.setRequestHeader('X-Ant-Challenge-Id', challengeId); } },});hls.loadSource(playlistUrl);Shaka Player
player.getNetworkingEngine().registerRequestFilter(async (type, request) => { const url = new URL(request.uris[0]); const timestamp = Math.floor(Date.now() / 1000); request.headers['X-Ant-Session'] = sessionId; request.headers['X-Ant-Timestamp'] = String(timestamp); request.headers['X-Ant-Signature'] = await sign(`${sessionId}:${videoId}:${timestamp}:${url.pathname}`); if (url.pathname.endsWith('/aes-key')) { request.headers['X-Ant-Challenge-Id'] = challengeId; }});The hosted AntCDN player
The iframe player at player.antcdn.net implements all of the above. Give it a session in one of these ways, listed strongest first:
| Method | How | Refreshable |
|---|---|---|
sessionEndpoint | ?sessionEndpoint=https://your-app/playback-session — the player calls your endpoint, which proxies /v1/playback/init and returns its JSON verbatim | Yes |
session=parent | ?session=parent — the player asks the embedding page for a session over postMessage | Yes |
sessionData | ?sessionData={base64url JSON} — the init response, encoded | No |
| Inline params | ?sessionId=...&sessionSecret=...&challengeId=...&assetId=... | No |
Prefer sessionEndpoint or session=parent. Only those two can mint a replacement session, so only those two survive an expired session or a spent challenge. The inline forms also put the session secret in a URL, where it ends up in browser history and referrer headers.
Player requirements
The player must be able to set request headers on key and segment requests. That rules out players that hand the URL to the OS media stack:
- Safari / iOS native HLS (a bare
<video src>orhls.jsfalling back to native) cannot set headers on key requests. On Safari, AntCDN serves the FairPlay rendition frommaster.m3u8instead — a different integration, covered separately. - AVPlayer, ExoPlayer, TV players: use their key/loader delegate hooks (
AVAssetResourceLoader, ExoPlayerDataSource.Factory) to attach the headers, or proxy through your own app layer.
Anything MSE-based (HLS.js, Shaka, Video.js with either engine) works.
Errors
| Status | Body | What it means | Fix |
|---|---|---|---|
401 | Missing playback auth headers | One of the three headers is absent | Check the request filter covers all request types, not just the manifest |
401 | Invalid timestamp / Timestamp out of tolerance window | Clock drift over 30s, or a cached timestamp | Recompute per request; check client clock |
401 | Session not found or expired | Unknown sessionId, or past 24h | Mint a new session |
401 | Missing X-Ant-Challenge-Id header | Key request without the challenge header | Add it on /aes-key only |
403 | Session video mismatch | Signature used the wrong videoId | Use the videoId from /v1/playback/init |
403 | Invalid signature | Wrong key encoding, message format, or a pathname that included the query string | Hex-decode the secret; sign pathname only |
403 | Invalid challenge ID | challengeId doesn’t belong to this session | Send the one from the same init response |
409 | Challenge already consumed | The key was already delivered for this session | Mint a new session and reload |
503 | Session lookup failed | Transient edge error | Retry once, then re-init |
Verifying an integration
POST /v1/playback/initreturns all six fields.- Master playlist returns
200with the three headers,401without them. - Variant playlists return
200— this is where a request filter scoped only to the manifest fails. /aes-keywith the challenge header returns exactly 16 bytes, and video plays.- A second
/aes-keyon the same session returns409. - A signature built from your own internal video ID returns
403 Session video mismatch. - Fetching a segment directly in a browser tab yields bytes that are not playable video.
What this does and does not protect
Encrypted segments raise the cost of casual copying substantially: scraping the CDN gets you ciphertext, and the key is behind a per-viewer session and a one-shot challenge.
It is not DRM. The key reaches the browser in plaintext, because the browser has to decrypt with it. A determined user with devtools can extract it. If you need output protection, hardware-backed key handling, or licence revocation, that requires FairPlay/Widevine — talk to us.