EME Workflow
Encrypted Media Extensions: from encrypted event to license.
What EME is
EME (Encrypted Media Extensions) is the W3C JavaScript API browsers expose for DRM-protected playback. It's deliberately key-system-agnostic: the same code drives Widevine, PlayReady, or FairPlay depending on what the browser supports.
EME deliberately offloads two things to the application:
- Network calls between the CDM and the licence server. The browser hands you opaque message bytes; you POST them; you feed the response back.
- Init data extraction, when needed. For CENC content, the browser extracts PSSH and gives you the init data for free.
This split is why EME integrations have so many places to go wrong — every byte that crosses the application is a place where wiring can break.
The complete flow
App — requestMediaKeySystemAccess('com.widevine.alpha', config)
The app describes the codecs and robustness it needs. The browser returns a MediaKeySystemAccess if the CDM supports the requested capabilities.
Code skeleton
The shape of the integration in real JavaScript:
async function play(video, src, licenseUrl) {
// 1. Capability check
const access = await navigator.requestMediaKeySystemAccess(
"com.widevine.alpha",
[{
initDataTypes: ["cenc"],
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.640028"',
robustness: "SW_SECURE_CRYPTO", // L3-style; "HW_SECURE_ALL" for L1
}],
}]
);
// 2. MediaKeys + bind
const mediaKeys = await access.createMediaKeys();
await video.setMediaKeys(mediaKeys);
// 3. Source
video.src = src;
// 4. Encrypted event drives session creation
video.addEventListener("encrypted", async (e) => {
const session = mediaKeys.createSession("temporary");
session.addEventListener("message", async (event) => {
const resp = await fetch(licenseUrl, {
method: "POST",
headers: { "Authorization": `Bearer ${appToken}` },
body: event.message,
});
if (!resp.ok) throw new Error("licence failed");
const buf = await resp.arrayBuffer();
// 7. Feed back to CDM
await session.update(buf);
});
// 4. Build the request
await session.generateRequest(e.initDataType, e.initData);
});
await video.play();
}
Forgetting to attach the message handler before calling generateRequest is a classic mistake — by the time you attach, the message has already fired into the void.
Session types
EME defines three session types, set when you call createSession(type):
| Type | Use case | Notes |
|---|---|---|
"temporary" | VOD / live streaming | Default. Keys live in memory; closed when session is. |
"persistent-license" | Download-to-go offline | CDM persists the licence; load(sessionId) brings it back later. |
"persistent-usage-record" | Audit / consumption tracking | Persists usage data, not the licence itself. Less common. |
Robustness strings
The robustness field in videoCapabilities is how the application asks for a security level on Widevine:
""(empty) — let the CDM decide."SW_SECURE_CRYPTO"— L3-equivalent: software CDM with software crypto."SW_SECURE_DECODE"— software CDM, decode also software, but secure path."HW_SECURE_CRYPTO"— keys in TEE; decode in rich OS (≈ L2)."HW_SECURE_DECODE"— keys + decode in TEE for video (≈ L1 video path)."HW_SECURE_ALL"— full L1 — keys, decode, all in TEE.
UHD policies typically demand HW_SECURE_ALL. If the device can't satisfy the requested robustness, requestMediaKeySystemAccess rejects.
Common error sites
Mismatched messageType. When renewal arrives, the app must still POST it but may need to attach a different auth context. Branch on event.messageType.
Calling setMediaKeys after video.src. Order matters: bind keys first, then attach source. Otherwise the encrypted event may fire before keys are bound and playback fails.
Not handling 'waitingforkey'. This fires when a sample needs a key the CDM doesn't have — typical at key-rotation boundaries if renewal is broken. If you ignore it, the user sees an indefinite stall.