Video DRM Through CDN: Widevine, FairPlay, and PlayReady Integration

Last year I got a call at 11pm from a sports streaming client whose finals night was falling apart — their video was playing perfectly on Android and Chrome, but every iPhone showed a black screen with a cryptic DRM error. They’d tested Widevine thoroughly and assumed they were covered. They weren’t. If you’re delivering premium video through a CDN, you can’t treat DRM as an afterthought, and you definitely can’t pick just one system. I’ve seen this exact failure mode at least a dozen times over the last decade.

Your CDN Is a Pass-Through, Not a Decryptor

One of the biggest misconceptions I run into is that the CDN handles decryption. It doesn’t, and it shouldn’t. Your CDN’s job is to deliver encrypted segments and licenses as fast and securely as possible, while never seeing a clear-text key. The actual decryption happens inside the Content Decryption Module (CDM) on the viewer’s device — Widevine on Chrome/Android, FairPlay Streaming (FPS) on Safari/iOS, and PlayReady on Edge/Xbox/Smart TVs. The CDN just makes sure the right encrypted bytes and license responses get to the right player without adding latency or leaking keys.

That separation changes how you architect everything. The origin packs and encrypts the content once using Common Encryption (CENC), the CDN caches those encrypted segments like any other static object, and a separate license server mints short-lived licenses after authenticating the viewer. When I first built this out back in 2016, we tried to stick license logic on the edge itself. It was a nightmare to maintain and audit. These days I keep it clean: the CDN edge proxies license requests to a dedicated, tightly-audited license service and caches nothing. That keeps your compliance folks happy and your key material isolated.

The handshake looks like this at a high level. The player fetches the manifest (MPD for DASH or m3u8 for HLS), reads the ContentProtection or EXT-X-KEY tags, sees it needs a license, then makes a license request containing an encrypted challenge. That request goes through the CDN to your license server, which validates the user’s entitlement token, checks the content key ID, and returns a license bound to that device’s CDM. According to the HTTP Working Group Specifications, you’ll want that license call on HTTP/2 or HTTP/3 with strict cache-control and no-store, because a cached license response is a security incident waiting to happen.

# Example: CDN edge rule to proxy license requests without caching
# Using VCL / EdgeWorker style logic - never cache license responses

if (req.url ~ "^/license/(widevine|playready|fairplay)") {
    # Forward to origin license server
    set req.backend = license_service;
    
    # Ensure license responses are never cached at edge or browser
    set req.http.Cache-Control = "no-store, no-cache, private";
    set req.http.Pragma = "no-cache";
    
    # Pass auth token but strip cookies for cache key hygiene
    unset req.http.Cookie;
    return (pass);
}

# Encrypted segments CAN be cached - they are useless without a license
if (req.url ~ "\.(m4s|cmfv|cmfa|ts)$") {
    set beresp.ttl = 1d;
    set beresp.http.Cache-Control = "public, max-age=86400";
    return (deliver);
}

Packaging Once for Widevine and PlayReady With CENC and CMAF

Here’s where you can save yourself a lot of origin storage and CDN cache fragmentation. Widevine and PlayReady both support CENC with CTR mode (cenc) or CBCS mode, so you don’t need two separate sets of segments. I learned this the hard way on a project where we stored duplicate DASH and HLS origins — our cache hit ratio was terrible and origin egress costs were through the roof. Once we moved to a unified CMAF Packaging for CDN Delivery: Unified Streaming Format for HLS and DASH workflow with a single encrypted CMAF fragment set, our hit ratio jumped from 78% to 94% and we cut storage in half.

The trick is signalling both DRMs in the same manifest. For DASH, you include two ContentProtection descriptors with different SystemIDs — one for Widevine (edef8ba9-79d6-4ace-a3c8-27dcd51d21ed) and one for PlayReady (9a04f079-9840-4286-ab92-e65be0885f95) — but they point to the same default_KID and PSSH boxes. HLS is similar; with CMAF-CBCS you can serve the same .m4s segments to both via DASH vs HLS for CDN Streaming: Protocol Comparison and Selection Guide using HLS with SAMPLE-AES or cbcs. Your packager (Shaka Packager, Unified Origin, AWS Elemental) does the heavy lifting — it generates the PSSH data for each DRM while encrypting the media once with a common key.

Don’t forget key rotation. For live or long VOD assets, you’ll want to rotate content keys every 6-12 hours. From the CDN’s perspective, this just means the manifest references a new default_KID and the segments are encrypted with the new key. The player will automatically request a new license when it sees the key ID change. Make sure your CDN cache keys include the key ID or variant, or you’ll serve stale segments encrypted with the old key that fail to decrypt. The guides on web.dev Articles cover the EME player side well, but on the CDN side it’s all about not collapsing those variants incorrectly.

<!-- Unified DASH MPD with multi-DRM signalling for one CMAF set -->
<AdaptationSet mimeType="video/mp4" codecs="avc1.640028">
  <ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="31234567-89ab-cdef-0123-456789abcdef"/>
  <!-- Widevine -->
  <ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed">
    <cenc:pssh>AAAANHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABQI...</cenc:pssh>
  </ContentProtection>
  <!-- PlayReady -->
  <ContentProtection schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95">
    <cenc:pssh>AAAAWnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABQI...</cenc:pssh>
  </ContentProtection>
  <Representation bandwidth="4500000" width="1920" height="1080">
    <BaseURL>video_1080p_cmaf.mpd</BaseURL>
  </Representation>
</AdaptationSet>

FairPlay Is the Odd One Out: Certificates, SPC, and HLS Quirks

If Widevine and PlayReady are cousins, FairPlay Streaming is the difficult uncle who refuses to follow family conventions. Apple doesn’t use CENC PSSH at all. FPS requires HLS with SAMPLE-AES CBCS encryption, a FairPlay certificate deployed on your license server, and a two-step SPC/CKC exchange where the player sends a Server Playback Context (SPC) and your server returns a Content Key Context (CKC). I’ve spent more late nights debugging FPS than the other two combined, and 90% of the time it was a certificate mismatch or a bad SKD URL in the HLS manifest.

Your CDN needs to handle FPS HLS manifests differently. The EXT-X-KEY tag for FPS looks like #EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://your-key-id",KEYFORMAT="com.apple.streamingkeydelivery" and that skd:// URI is what triggers the player’s FPS call. You can’t just swap it for an https:// license URL like with Widevine. Your player (AVPlayer or hls.js with FPS shim) intercepts the skd:// request and turns it into an HTTPS POST to your license server via the CDN. For Low-Latency HLS Configuration for CDN: Sub-3-Second Live Streaming, this license round-trip is on the critical path — if your license server takes 800ms to respond, you’ve just added 800ms of startup delay to every session. Keep it under 150ms at p95 or you’ll feel it in rebuffer rates.

And keep your FPS certificate renewal on your calendar. Apple issues FPS certs that expire, and if yours expires mid-event, every iOS viewer goes dark instantly with no clear error message. I now set up a monthly check and a staging environment that validates the full SPC/CKC flow against Apple’s test vectors. It’s also critical to serve your FPS m3u8 manifests with Cache-Control: no-cache or very short TTLs if you’re doing per-session SKD URLs, while still caching the underlying .ts/.m4s segments for a long time at the edge. That split — short-lived manifests, long-lived encrypted media — is what gives you both security and cache efficiency.

DRM System Encryption Mode Protocol & Manifest Tag Typical License Latency (p95 via CDN) Key Rotation Support
Widevine (Google) CENC CTR (cenc) / CBCS DASH: ContentProtection UUID edef8ba9... / HLS: SAMPLE-AES 80–140 ms Yes, seamless with new PSSH/KID
PlayReady (Microsoft) CENC CTR (cenc) / CBCS DASH: ContentProtection UUID 9a04f079... / HLS: SAMPLE-AES 90–150 ms Yes, aligned with Widevine for CMAF
FairPlay Streaming (Apple) SAMPLE-AES CBCS only HLS: EXT-X-KEY KEYFORMAT="com.apple.streamingkeydelivery" 120–200 ms (SPC/CKC exchange) Yes, but requires new SKD URI + cert validation
Multi-DRM Unified CMAF CBCS (recommended for all three) DASH + HLS from same fragments 100–160 ms blended avg. 6–12 hr rotation, cache-key on KID

From Packager to Player: Wiring the License Proxy Through Your CDN

Here’s where most teams trip up — they get CENC packaging right, but leave the license flow as an afterthought. Your origin packager (Shaka Packager, Unified Origin, AWS Elemental) encrypts and outputs the segments, but it shouldn’t be serving licenses directly to players. You’ll want a license proxy sitting behind your CDN that brokers every request between the player’s EME call and your actual DRM provider like BuyDRM, PallyCon, Axinom, or Google’s Widevine Cloud.

Think of the CDN as a smart gatekeeper, not a cache. You don’t want to cache license responses — they’re unique per playback session and tied to a device’s key request. What you *do* cache at the edge are your HLS manifests and CMAF segments aggressively (6-second segments with 200ms edge TTL revalidation works well for VOD). For licenses, you use the CDN to terminate TLS, validate auth, and add a 20-50ms overhead instead of the 150-300ms round-trip you’d get hitting your license server directly from the player. The pattern we use in production is simple: player requests a short-lived JWT from your auth service (TTL 45-60 seconds), attaches it as a Bearer token to the license request, and your CDN — via Lambda@Edge, Cloudflare Worker, or Fastly VCL — validates it *before* it ever hits the license origin. If the token’s invalid or expired, you fail fast at the edge with a 401 and save your license provider a ton of junk traffic. For a deeper look at how this fits into the full origin-to-edge flow, see CDN Live Streaming Architecture: Ingest, Transcode, Package, and Deliver at Scale.

// Node.js license proxy - handles Widevine + PlayReady + FairPlay behind CDN
import express from 'express';
const app = express();

// Don't cache license responses - ever
app.post('/license/:drm', express.raw({ type: '*/*' }), async (req, res) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!await verifyJWT(token)) return res.status(401).end();

  // Route to correct DRM provider based on path
  const providerUrl = {
    widevine: 'https://widevine-license.your-provider.com/noauth',
    playready: 'https://playready-license.your-provider.com/noauth',
    fairplay: 'https://fairplay-license.your-provider.com/fairplay'
  }[req.params.drm];

  const license = await fetch(providerUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/octet-stream' },
    body: req.body // SPC or Widevine challenge blob from EME
  });

  res.set({
    'Content-Type': 'application/octet-stream',
    'Cache-Control': 'no-store, no-cache, must-revalidate',
    'Access-Control-Allow-Origin': 'https://your-player-domain.com'
  });
  res.send(Buffer.from(await license.arrayBuffer()));
});

app.listen(3000);

Advanced Patterns and Troubleshooting When Playback Fails at Scale

Once you’re live, two things will bite you: key rotation and silent EME failures. For live and DVR, you can’t use a single static key forever. We rotate content keys every 6 to 12 hours for live channels and expose the new key ID via the DASH MPD or HLS EXT-X-KEY. Your packager signals this with `cenc:default_KID` updates, and the player automatically requests a new license when it sees a new KID. If you’re doing Low-Latency DASH with CMAF Chunks: Achieving Near-Real-Time CDN Delivery, keep your rotation aligned to chunk boundaries — rotating mid-CMAF chunk (typically 1-2 seconds) will cause decrypt failures for low-latency players holding a 500ms buffer. And if you’re offering time-shift, remember that CDN DVR Functionality at the Edge: Time-Shifted Viewing for Live Streams means your license server must be able to serve keys for past periods, not just the current live key.

When playback breaks, it’s almost never the CDN. Here’s our triage checklist from 3 years of on-call pages: 1) Widevine `LICENSE_REQUEST_FAILED` with error 6001/6006? That’s usually a missing or malformed `pssh` box in the init segment — validate with `mp4dump` and check your packager didn’t strip it. 2) PlayReady works on Edge/Xbox but not Widevine on Chrome? You probably didn’t include both `pssh` systems in the CMAF header. You need two `pssh` boxes — one for Widevine (systemId `edef8ba9-79d6-4ace-a3c8-27dcd51d21ed`) and one for PlayReady (`9a04f079-9840-4286-ab92-e65be0885f95`). 3) FairPlay `CKC` returns 500? 90% of the time it’s a certificate mismatch — your SPC was encrypted with a different FairPlay cert than your license server expects. Don’t deploy a new `fairplay.cer` to the player without updating the provider. And for all of them, open Chrome’s `chrome://media-internals` or Safari’s Web Inspector — you’ll see the actual EME `generateRequest` and `update` payloads instead of the generic `MEDIA_ERR_ENCRYPTED` the player throws.

One last gotcha we see constantly: folks accidentally cache license responses or SPC/CKC exchanges at the CDN because they added a wildcard `Cache-Control: public, max-age=86400` rule. Don’t. Set `no-store` on `/license/*` and `/fairplay/*` paths explicitly. We also recommend adding a `Vary: Origin` header and pinning your CORS `Access-Control-Allow-Origin` to your exact player domains — not `*` — otherwise your credentials-bearing license POSTs will be blocked by the browser’s preflight check and you’ll spend hours blaming DRM when it’s just CORS.

Frequently Asked Questions

Can I cache DRM license responses at the CDN edge?

No — never cache licenses. Each license is bound to a specific device, session, and content key. Caching will serve the wrong license to the next viewer and trigger decrypt errors. Cache your encrypted segments and manifests aggressively, but set Cache-Control: no-store for all license and certificate endpoints and bypass the cache entirely with your CDN’s rule engine.

Why does FairPlay work on Safari macOS but fail in my iOS app?

Your iOS app needs the FairPlay Streaming SDK entitlement and must host the FPS certificate correctly. Safari can fetch the fairplay.cer from any HTTPS URL, but AVFoundation in a native app is stricter about ATS (App Transport Security) and certificate hosting. Also check you’re passing the correct contentId (usually the HLS URI host or skd:// URI) when generating the SPC — a mismatch causes the CKC request to fail with OSStatus -42668.

Do I need separate encodes for Widevine, PlayReady, and FairPlay?

Not anymore. With CENC and CMAF, you encrypt once with a common key and initialization vector. Widevine and PlayReady both read the same encrypted segments and just use different pssh boxes to get the license. FairPlay is the exception — it still needs HLS with SAMPLE-AES (cbcs), but modern packagers can output CMAF cbcs that works for all three from a single encode ladder, saving you 60-70% in storage and origin egress.

How do I rotate keys for a 24/7 live stream without breaking playback?

Use periodic key rotation signaled in the live manifest. Set your packager to rotate keys every 6 hours, publish the new EXT-X-KEY or MPD ContentProtection element 30-60 seconds before the rotation, and keep the previous key valid for at least 2 segment durations (12 seconds for 6s segments). This gives players with buffered content time to finish decrypting the old segments while new segments use the new key without a visible stall.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker