Skip to main content

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.

Terminal window
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
}'
FieldTypeDefaultDescription
encryptSegmentsbooleanfalseProduce 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

  1. Generates a random 16-byte AES-128 content key, unique to that video.
  2. Packages an encrypted rendition set alongside the clear one.
  3. 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.
  4. 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/init
X-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"
}
FieldUse
sessionIdSent as X-Ant-Session on every request
sessionSecretHMAC key used to sign each request. Treat it like a password
videoIdYour asset ID. Goes into every signature — returned here so you never have to look it up
challengeIdSent as X-Ant-Challenge-Id on the key request only
expiresAtUnix milliseconds. Sessions live 24 hours
playlistUrlWhere 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 400 for 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 the X-Ant-Timestamp header. 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.m3u8

Step 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:

  1. Mint per playback start. Call /v1/playback/init fresh every time the player mounts, and hand the result straight to the player. Simplest, and correct.
  2. 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 on 409 or 401. 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:

MethodHowRefreshable
sessionEndpoint?sessionEndpoint=https://your-app/playback-session — the player calls your endpoint, which proxies /v1/playback/init and returns its JSON verbatimYes
session=parent?session=parent — the player asks the embedding page for a session over postMessageYes
sessionData?sessionData={base64url JSON} — the init response, encodedNo
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> or hls.js falling back to native) cannot set headers on key requests. On Safari, AntCDN serves the FairPlay rendition from master.m3u8 instead — a different integration, covered separately.
  • AVPlayer, ExoPlayer, TV players: use their key/loader delegate hooks (AVAssetResourceLoader, ExoPlayer DataSource.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

StatusBodyWhat it meansFix
401Missing playback auth headersOne of the three headers is absentCheck the request filter covers all request types, not just the manifest
401Invalid timestamp / Timestamp out of tolerance windowClock drift over 30s, or a cached timestampRecompute per request; check client clock
401Session not found or expiredUnknown sessionId, or past 24hMint a new session
401Missing X-Ant-Challenge-Id headerKey request without the challenge headerAdd it on /aes-key only
403Session video mismatchSignature used the wrong videoIdUse the videoId from /v1/playback/init
403Invalid signatureWrong key encoding, message format, or a pathname that included the query stringHex-decode the secret; sign pathname only
403Invalid challenge IDchallengeId doesn’t belong to this sessionSend the one from the same init response
409Challenge already consumedThe key was already delivered for this sessionMint a new session and reload
503Session lookup failedTransient edge errorRetry once, then re-init

Verifying an integration

  1. POST /v1/playback/init returns all six fields.
  2. Master playlist returns 200 with the three headers, 401 without them.
  3. Variant playlists return 200 — this is where a request filter scoped only to the manifest fails.
  4. /aes-key with the challenge header returns exactly 16 bytes, and video plays.
  5. A second /aes-key on the same session returns 409.
  6. A signature built from your own internal video ID returns 403 Session video mismatch.
  7. 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.