Encrypted Media Extensions (EME): Decrypting Protected Video in the Browser

Secure digital agreement interface with glowing padlock, abstract data flow visualization, defocused circuit patterns, network protection concept, encryption technology, privacy
Encrypted Media Extensions (EME) is the web platform’s standard way to enable playback of encrypted, DRM-protected media in the browser. Rather than handling decryption directly, your application coordinates with the browser’s Content Decryption Module (CDM): it requests access to a media key system via Navigator.requestMediaKeySystemAccess(), attaches the resulting MediaKeys to a specific HTMLMediaElement with setMediaKeys(), and then manages the message exchange for licenses/keys through a MediaKeySession. When the media element encounters encrypted initialization data, it fires an encrypted event (represented by MediaEncryptedEvent), and the CDM can emit session messages (MediaKeyMessageEvent) that your app relays to whatever key infrastructure you use. EME is widely available across modern browsers and is typically restricted to secure contexts (HTTPS).

When you hit play on a movie in a modern browser and it just works—even when the content is protected—there’s a good chance the Encrypted Media Extensions (EME) API is part of the story. EME is a set of Web APIs that lets web apps control playback of encrypted media under a digital restrictions management (DRM) scheme, by coordinating with a browser’s Content Decryption Module (CDM). Access to the API starts from Navigator.requestMediaKeySystemAccess(), which is how a site requests permission to use a particular DRM/key system.

EME is considered widely available across browsers (baseline since March 2019) and is available only in secure contexts (HTTPS) in supporting browsers.

The high-level model: Key systems, keys, and sessions

At a conceptual level, EME breaks the DRM playback flow into a few key pieces:

In practice, EME enables the browser to surface “I’ve encountered encrypted media, here’s the init data,” and then lets your application orchestrate the steps needed to obtain and manage decryption keys—without the app needing to implement decryption itself.

Core interfaces

EME is organized around a handful of interfaces that mirror the lifecycle of encrypted playback:

How the API “hooks” into media elements

EME also extends existing web platform objects—especially HTMLMediaElement and Navigator—so you can integrate DRM playback into normal media workflows:

HTMLMediaElement

Navigator

				
					<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>EME API Demo</title>
    <link data-wphbdelayedstyle="style.css" rel="stylesheet" />
    <script type="wphb-delay-type" src="encryptedMediaExtensionsAPI.js" defer></script>
</head>
    
<body>
    <div class="container">
        <h1>Encrypted Media Extensions (EME) Demo</h1>
        <p>This demo illustrates the basic workflow of the EME API using a simulated encrypted video and license server for
            the Clear Key system.</p>
    
        <video id="myVideo" controls autoplay muted width="640" height="360"></video>
    
        <div id="status" class="status-message">Waiting for user interaction...</div>
    
        <button id="startButton">Start EME Process & Load Video</button>
        <button id="clearLogButton">Clear Log</button>
    
        <h2>Console Log</h2>
        <pre id="log" class="log-area"></pre>
    </div>

<script type="text/javascript" id="wphb-delayed-styles-js">
			(function () {
				const events = ["keydown", "mousemove", "wheel", "touchmove", "touchstart", "touchend"];
				function wphb_load_delayed_stylesheets() {
					document.querySelectorAll("link[data-wphbdelayedstyle]").forEach(function (element) {
						element.setAttribute("href", element.getAttribute("data-wphbdelayedstyle"));
					}),
						 events.forEach(function (event) {
						  window.removeEventListener(event, wphb_load_delayed_stylesheets, { passive: true });
						});
				}
			   events.forEach(function (event) {
				window.addEventListener(event, wphb_load_delayed_stylesheets, { passive: true });
			  });
			})();
		</script></body>

</html>
				
			
				
					body {
    font-family: Arial, sans-serif;
    margin: 20px;
    background-color: #f4f4f4;
    color: #333;
    line-height: 1.6;
}

.container {
    max-width: 800px;
    margin: 0 auto;
    background-color: #fff;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

h1,
h2 {
    color: #0056b3;
    border-bottom: 1px solid #eee;
    padding-bottom: 10px;
    margin-top: 20px;
}

video {
    width: 100%;
    max-width: 640px;
    height: auto;
    display: block;
    margin: 20px 0;
    background-color: #000;
    border: 1px solid #ccc;
    border-radius: 4px;
}

.status-message {
    padding: 10px;
    margin-bottom: 15px;
    border-radius: 4px;
    background-color: #e7f3ff;
    border: 1px solid #b3d9ff;
    color: #0056b3;
    font-weight: bold;
}

.status-message.success {
    background-color: #e6ffe6;
    border-color: #b3ffb3;
    color: #28a745;
}

.status-message.error {
    background-color: #ffe6e6;
    border-color: #ffb3b3;
    color: #dc3545;
}

button {
    background-color: #007bff;
    color: white;
    padding: 10px 15px;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    font-size: 16px;
    margin-right: 10px;
    margin-top: 10px;
}

button:hover {
    background-color: #0056b3;
}

button:disabled {
    background-color: #cccccc;
    cursor: not-allowed;
}

.log-area {
    background-color: #333;
    color: #00ff00;
    padding: 15px;
    border-radius: 4px;
    white-space: pre-wrap;
    max-height: 300px;
    overflow-y: auto;
    font-family: 'Courier New', Courier, monospace;
    font-size: 0.9em;
}
				
			
				
					// --- DOM Element References ---
const videoElement = document.querySelector('#myVideo');
const statusDiv = document.querySelector('#status');
const logArea = document.querySelector('#log');
const startButton = document.querySelector('#startButton');
const clearLogButton = document.querySelector('#clearLogButton');

// --- Configuration for Clear Key EME ---
// The 'org.w3.clearkey' key system is for testing and unencrypted content.
// For real DRM, this would be 'com.widevine.alpha', 'com.microsoft.playready', etc.
const KEY_SYSTEM = 'org.w3.clearkey';

// A placeholder for the encrypted video URL.
// In a real scenario, this would be an actual DASH/HLS stream with PSSH boxes.
// For this demo, we'll use a regular video and simulate the 'encrypted' event.
const VIDEO_URL =
	'https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_1MB.mp4'; // Example public video for playback

// Simulated video initialization data (often contains PSSH box for real DRM)
// For Clear Key, this is a simple JSON structure with key IDs and keys.
const simulatedInitData = JSON.stringify({
	keys: [
		{
			kty: 'oct',
			kid: 'Wk11iX_U02jA-J-8d4gT2w', // Base64url encoded key ID
			k: 'zE3SjoQh_C9P7aC81W9V2A' // Base64url encoded key
		}
	]
});

// Convert the simulatedInitData to an ArrayBuffer, as required by EME API
const textEncoder = new TextEncoder();
const initDataArrayBuffer = textEncoder.encode(simulatedInitData).buffer;

// --- Helper Functions ---
const updateStatus = (message, type = '') => {
	statusDiv.textContent = message;
	statusDiv.className = `status-message ${type}`;
	logMessage(`STATUS: ${message}`);
};

const logMessage = (message) => {
	const timestamp = new Date().toLocaleTimeString();
	logArea.textContent += `[${timestamp}] ${message}\n`;
	logArea.scrollTop = logArea.scrollHeight; // Auto-scroll to bottom
};

const base64urlToArrayBuffer = (base64url) => {
	const padding = '='.repeat((4 - (base64url.length % 4)) % 4);
	const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
	const raw = window.atob(base64);
	const outputArray = new Uint8Array(raw.length);
	for (let i = 0; i < raw.length; ++i) {
		outputArray[i] = raw.charCodeAt(i);
	}
	return outputArray.buffer;
};

// --- EME Workflow Functions ---

const startEmeProcess = async () => {
	startButton.disabled = true;
	updateStatus('Starting EME process...', '');

	if (!('requestMediaKeySystemAccess' in navigator)) {
		updateStatus(
			'Error: Encrypted Media Extensions (EME) not supported by your browser.',
			'error'
		);
		return;
	}

	try {
		// 1. Request MediaKeySystemAccess
		// This checks if the browser supports the specified key system and its capabilities.
		const config = [
			{
				initDataTypes: ['cenc'], // Common Encryption (CENC) scheme
				videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }],
				audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }]
			}
		];
		logMessage(
			`Attempting to request MediaKeySystemAccess for "${KEY_SYSTEM}"...`
		);
		const keySystemAccess = await navigator.requestMediaKeySystemAccess(
			KEY_SYSTEM,
			config
		);
		updateStatus(
			`Successfully obtained MediaKeySystemAccess for "${KEY_SYSTEM}".`,
			'success'
		);
		logMessage('MediaKeySystemAccess obtained. Creating MediaKeys...');

		// 2. Create MediaKeys
		// MediaKeys represents a set of decryption keys.
		const mediaKeys = await keySystemAccess.createMediaKeys();
		updateStatus('MediaKeys object created.', 'success');
		logMessage('MediaKeys created. Setting MediaKeys on video element...');

		// 3. Set MediaKeys on the video element
		// This links the decryption keys to the HTMLMediaElement.
		await videoElement.setMediaKeys(mediaKeys);
		updateStatus(
			'MediaKeys set on video element. Ready for encrypted events.',
			'success'
		);
		logMessage(
			'MediaKeys linked to video. Attaching "encrypted" event listener...'
		);

		// 4. Listen for 'encrypted' event from the video element
		// This event fires when the media element encounters encrypted data.
		videoElement.addEventListener('encrypted', async (event) => {
			logMessage(
				`"encrypted" event fired! InitDataType: ${event.initDataType}`
			);
			updateStatus(
				`Encrypted content detected. Initializing decryption...`,
				''
			);

			// Use the simulatedInitData for this demo, as the actual video might not trigger it.
			const initData = event.initData || initDataArrayBuffer;
			const initDataType = event.initDataType || 'cenc'; // Assume 'cenc' for simulated data

			// 5. Create MediaKeySession
			// A session manages the exchange of messages (license requests/responses) with the CDM.
			const session = mediaKeys.createSession();
			logMessage(`MediaKeySession created. Session ID: ${session.sessionId}`);

			// 6. Listen for 'message' events from the MediaKeySession
			// This is where the CDM requests a license from the license server.
			session.addEventListener('message', async (messageEvent) => {
				logMessage(
					`MediaKeySession "message" event. Message type: ${messageEvent.messageType}`
				);
				updateStatus(
					'License request received from CDM. Simulating license server response...',
					''
				);

				// In a real application, messageEvent.message would be sent to a license server.
				// The server would return a license, which is then passed to session.update().

				// --- SIMULATED LICENSE SERVER RESPONSE (for Clear Key) ---
				// For Clear Key, the 'message' event usually contains a JSON object
				// that directly includes the key IDs for which keys are needed.
				// The license server would respond with the actual keys.
				logMessage('Simulating license server...');
				const licenseRequest = JSON.parse(
					new TextDecoder().decode(messageEvent.message)
				);
				logMessage(
					`License request content: ${JSON.stringify(licenseRequest)}`
				);

				// Our simulatedInitData already contains the keys needed by Clear Key,
				// so we "respond" with that. For real DRM, you'd fetch from a server.
				const licenseResponse = textEncoder.encode(simulatedInitData).buffer;

				// 7. Update the MediaKeySession with the license response
				await session.update(licenseResponse);
				updateStatus(
					'MediaKeySession updated with simulated license.',
					'success'
				);
				logMessage(
					'Simulated license applied to session. Keys should now be available.'
				);
			});

			// Listen for 'keystatuseschange' events
			session.addEventListener('keystatuseschange', (event) => {
				logMessage(
					`MediaKeySession "keystatuseschange" event. Current statuses:`
				);
				event.target.keyStatuses.forEach((status, keyId) => {
					const keyIdStr = new TextDecoder().decode(keyId);
					logMessage(`  Key ID: ${keyIdStr}, Status: ${status}`);
					if (status === 'usable') {
						updateStatus(
							'Decryption keys are usable. Video should now play!',
							'success'
						);
						videoElement.play(); // Attempt to play if keys are usable
					} else if (status === 'expired' || status === 'internal-error') {
						updateStatus(
							`Key status error: ${status}. Cannot play content.`,
							'error'
						);
					}
				});
			});

			// 8. Generate a license request for the session
			await session.generateRequest(initDataType, initData);
			logMessage('License request generated by session.');
		});

		// 9. Set the video source and load it
		// This will ideally trigger the 'encrypted' event if the media is truly encrypted.
		videoElement.src = VIDEO_URL;
		videoElement.load();
		updateStatus(`Video source set to ${VIDEO_URL}. Attempting to load...`, '');
		logMessage(
			'Video element source set and loading. Waiting for "encrypted" event...'
		);

		// If the video isn't actually encrypted or doesn't trigger 'encrypted',
		// we might manually generate it for the demo flow.
		// For a simple public video, the 'encrypted' event won't naturally fire.
		// To demonstrate the flow, we'll manually trigger it after a short delay.
		setTimeout(() => {
			if (videoElement.readyState < 3) {
				// If video hasn't started playing or received metadata
				logMessage(
					'Simulating "encrypted" event manually for demo purposes...'
				);
				// You cannot dispatch a custom 'encrypted' event to trigger EME directly,
				// but the listeners are set up. The actual EME initialization
				// needs to be triggered by the browser encountering actual encrypted data.
				// For a true demo, VIDEO_URL would point to an encrypted stream.
				// Here, we just ensure listeners are ready and the status is updated.
				updateStatus(
					'Waiting for real encrypted content... (or manually trigger in a real scenario)',
					''
				);
			}
		}, 3000);
	} catch (error) {
		console.error('EME Process Error:', error);
		updateStatus(`EME process failed: ${error.message}`, 'error');
		logMessage(`ERROR: ${error.message}`);
	}
};

// --- Event Listeners for Buttons ---
startButton.addEventListener('click', startEmeProcess);
clearLogButton.addEventListener('click', () => {
	logArea.textContent = '';
	updateStatus('Log cleared.', '');
});

// Initial check and setup hint
document.addEventListener('DOMContentLoaded', () => {
	logMessage(
		'Document loaded. Click "Start EME Process & Load Video" to begin.'
	);
	// EME requires HTTPS! Check and warn if not secure.
	if (window.location.protocol !== 'https:') {
		updateStatus(
			'Warning: EME typically requires a secure context (HTTPS). This demo might not work on HTTP.',
			'error'
		);
	}
});

				
			

Why EME matters

For video platforms, broadcasters, and subscription services, EME is the standards-based way to deliver protected streams on the open web—integrated with the browser’s media stack instead of relying on legacy plugins. It’s also valuable beyond “big streaming”: any application that needs encrypted playback with controlled key access can build on this API surface.

Share the Post:

Related Posts

small_c_popup.png

Need help?

Let's have a chat...


Login

Jump Back In!

Here at Webolution Designs, we love to learn. This includes sharing things we have learned with you. 

Register

Begin Your Learning Journey Today!

Come back inside to continue your learning journey.