Building Your Own DRM: A Case Study in Why You Shouldn't

I recently looked at the content protection of a European audiobook streaming platform. They decided to build their own DRM instead of using Widevine or FairPlay. It went about as well as you&#39;d expect. Names and identifiers are changed but the architecture and patterns are real. The Setup The platform lets subscribers download audiobooks for offline playback. Downloaded files are encrypted and stored on-device. So far, so normal. The interesting part is how they implemented it. Their key exchange uses JWE with an ECDH key agreement scheme. The negotiated content encryption key protects each audiobook file with AES-128-CTR. Keys are stored in Android Keystore after exchange. Sounds fine on paper. Three things make it fall apart. No Auth on the CDN The encrypted audiobook files sit on a CDN behind a predictable URL structure: https://cdn.redacted.net/api/aes/download/mp4/{asset_id}.mp4 asset_id is a UUID you get from the license API. The problem: the CDN endpoint requires zero authentication. $ curl -o audiobook.mp4 &quot;https://cdn.redacted.net/api/aes/download/mp4/a1b2c3d4e5f6.mp4&quot; % Total % Received 100 282M 100 282M 0 0 45.2M 0 0:00:06 0:00:06 --:--:-- 51.3M No cookies. No bearer token. No signed URL. Just a naked GET and you&#39;ve got 282MB of encrypted audiobook. &quot;But it&#39;s encrypted!&quot; — sure. And your front door is locked. But you left all your windows open, and we&#39;ll get to the lock in a second. The fix here is trivial: signed URLs with short TTLs. Every major CDN supports this natively. There is genuinely no reason to serve content — encrypted or not — without authentication. Decryption Lives in Java The entire crypto pipeline runs in the Java layer. After key exchange, the content key arrives as a raw byte[] in a class constructor: // ContentCipher.java public class ContentCipher { private final byte [] key; private final byte [] nonce; public ContentCipher ( byte [] key, long contentIdLsb) { this .key = key; this .nonce = buildNonce(contentIdLsb); } private byte [] buildNonce( long lsb) { byte [] iv = new byte [ 16 ]; ByteBuffer.wrap(iv).putLong(lsb); // bytes 0-7: content UUID lower bits // bytes 8-15: counter, starts at 0 return iv; } } This is the entire security boundary. A single Java class. The key exists as a plaintext byte array in application memory, passed through a hookable constructor. Anyone with Frida or a similar instrumentation framework can intercept this in about 10 lines: // hook.js Java . perform ( () =&gt; { var cipher = Java . use ( &#x27;com.example.drm.ContentCipher&#x27; ); cipher. $init . overload ( &#x27;[B&#x27; , &#x27;long&#x27; ). implementation = function ( key, lsb ) { console . log ( &#x27;[*] Key: &#x27; + toHex (key)); console . log ( &#x27;[*] LSB: &#x27; + lsb); return this .$init(key, lsb); }; }); That&#39;s it. Download an audiobook through the app, key shows up in your terminal. Compare this to Widevine L1: the key never leaves the TEE (Trusted Execution Environment). It goes from the license server → secure enclave → hardware decoder. The app process literally never sees the key. That&#39;s what actual content protection looks like. Deterministic IV AES-CTR needs a unique nonce per encryption. They derive it from the content UUID: # iv.py import struct, uuid def build_iv ( asset_id: str ) -&gt; bytes : u = uuid.UUID(asset_id) lsb = u. int &amp; 0xFFFFFFFFFFFFFFFF return struct.pack( &#x27;&gt;Q&#x27; , lsb) + struct.pack( &#x27;&gt;Q&#x27; , 0 ) # 16 bytes The IV is fully determined by a value the client already has. Not random, not derived from a KDF with user-specific salt — just the lower 64 bits of the content UUID, big-endian, padded with zeros. For CTR mode this isn&#39;t catastrophic by itself (each content UUID is unique, so nonce reuse doesn&#39;t happen). But it means the IV is not a secret. The only secret in the entire scheme is the 16-byte AES key. Once that&#39;s extracted, decryption is trivial: # decrypt.py from Crypto.Cipher import AES def decrypt ( asset_id: str , key_hex: str , data: bytes ) -&gt; bytes : key = bytes .fromhex(key_hex) iv = build_iv(asset_id) cipher = AES.new(key, AES.MODE_CTR, nonce= b&#x27;&#x27; , initial_value=iv) return cipher.decrypt(data) The Chain None of these are earth-shattering individually. Together they&#39;re a complete content extraction pipeline: Get asset_id from license API (legit subscriber) curl the CDN (no auth needed) Hook the constructor (Frida, 10 lines) Derive IV from asset_id (deterministic) AES-CTR decrypt ffplay decrypted.mp4 → 6+ hours of audiobook Issue Alone In Chain Unauthenticated CDN Medium Critical Key in Java layer Medium Critical Deterministic IV Low Amplifier The real kicker: keys are per-content, not per-user. A single subscriber can extract keys for every audiobook they download, publish a {isbn, asset_id, key} database, and anyone can download and decrypt without ever having a subscription. Why Custom DRM Fails This isn&#39;t a story about one company making a mistake. It&#39;s a pattern. Widevine, FairPlay, and PlayReady exist because content protection is one of those problems that looks simple (&quot;just encrypt it&quot;) but requires hardware-level integration to actually work. The moment your key touches the Java/Kotlin/Swift layer as a byte array, you&#39;ve lost. The moment your CDN serves content without auth, you&#39;ve lost. The moment your IV is deterministic, you&#39;ve made the attacker&#39;s job easier. If you&#39;re building a media platform and you&#39;re thinking about rolling your own DRM: don&#39;t. Widevine L1 licensing is free for Android. FairPlay is built into iOS. Use them. If you have to build custom (and I genuinely can&#39;t think of why), at minimum: Signed CDN URLs with per-session tokens and short TTLs Native (C++) decryption with integrity checks, not Java Per-user key derivation so leaked keys have limited blast radius Random IVs stored alongside the encrypted content Download anomaly detection — flag accounts pulling 500 audiobooks in a day But really, just use Widevine.