Build a DRM Token Vending System for Premium Video


security api-design reliability

System Design Deep Dive

DRM Token Vending System

40 million subscribers, one licensed stream each, revoked in seconds not hours.

⏱ 16 min read📐 Advanced🔒 Security

Picture a concert venue that sells tiered wristbands at the door. The wristband proves you paid, but security still scans it at every gate you pass through on your way to your seat, the bar, and back. If the box office issues a refund on your ticket mid-show, the very next gate you approach turns you away, even though the physical wristband on your arm looks completely unchanged. The wristband itself never had to be clawed back. The gates simply stopped honoring it.

A DRM token vending system is that gate-scanning layer for premium video. Every time a subscriber presses play, the system has to answer “is this specific person, on this specific device, still allowed to watch this specific piece of content, right now” and it has to keep answering that question for every segment of a two-hour movie, not just once at the start. At 40 million subscribers and 3 million concurrent streams during a peak event, that question gets asked roughly half a million times a second, and the answer has to be correct within seconds of a subscription lapsing, a password being shared, or an account being flagged for abuse.

The naive approach is to issue one long-lived DRM license per login and trust it for the rest of the session. That collapses under three forces. First, a license valid for hours means a canceled subscription, a fraud flag, or a stolen credential stays exploitable for however long that license has left to run, which is a standing security liability nobody wants to own. Second, checking entitlement against a central database on every single video segment request, at half a million requests a second, would either fall over or force you to run an origin database cluster sized for load that has nothing to do with your actual write volume. Third, DRM systems like Widevine and FairPlay control the content-key handshake inside the player’s secure media pipeline, which the CDN edge serving video segments cannot see or participate in, so the CDN needs its own independent, fast way to decide yes or no on every segment fetch.

The forces in tension are latency, consistency, and blast radius. We want every segment request answered in single-digit milliseconds with zero round trip to a central system, we want a canceled subscription to stop working within seconds rather than hours, and we want a single leaked credential or shared token to expose at most a tiny window of access rather than an entire subscription’s remaining lifetime. We need to solve for short-lived, locally verifiable tokens, device binding that makes token sharing across accounts costly, and a revocation propagation system fast enough that the token’s own expiry becomes the true worst-case exposure window, not the exception to it.

Requirements and Constraints

Functional Requirements

  • Issue a short-lived playback authorization token (PAT) after verifying an active subscription and a bound device
  • Bind each issued license to a specific device fingerprint and enforce a per-account cap on concurrently active devices
  • Renew a token transparently while a subscriber keeps watching, without forcing a full re-authentication
  • Validate every CDN segment request against the current token and a live revocation list without a round trip to origin
  • Revoke access for a specific user, device, or license instantly on cancellation, chargeback, password change, or detected abuse
  • Broker the actual content-key exchange with standard DRM systems (Widevine, FairPlay, PlayReady) after entitlement is confirmed

Non-Functional Requirements

  • Issuance latency: p99 under 150ms for a license request, including the upstream DRM key exchange
  • Edge verification latency: p99 under 5ms per segment request, with zero origin round trips on the happy path
  • Scale: 40 million active subscribers, 3 million concurrent streams at peak, roughly 500,000 edge verifications/sec globally
  • Revocation propagation: new segment requests must be denied within 5 seconds of a revocation event at steady state, with a hard worst-case bound of 90 seconds set by the token’s own TTL
  • Availability: 99.95% for the issuance path; the edge verification path must degrade gracefully, never fail open on a bad signature
  • Device binding: a hard cap of 4 concurrently active devices per account, enforced without false positives on legitimate device upgrades

Constraints and Assumptions

  • We are not designing the Widevine, FairPlay, or PlayReady content decryption module (CDM) internals - we design the broker layer in front of them that decides whether to hand out a license at all
  • Content is assumed already encrypted at rest and packaged into segments; the encoding and packaging pipeline is out of scope
  • The CDN edge runs a small, stateless compute layer capable of executing a verification function per request (the Lambda@Edge / Fastly Compute@Edge model)
  • Billing emits reliable subscription-state-changed events; we do not design the billing or payment system itself
  • Offline, downloaded playback uses a separate long-duration DRM license flow governed by the vendor’s own CDM policy - this post covers the online streaming entitlement path only

High-Level Architecture

The system has six major components: the Entitlement Gateway, the License Server, the Device Registry, the Revocation Service, the CDN Edge Verifier, and the Key Management Service, plus an external dependency on the subscriber’s actual DRM key provider (Widevine or FairPlay).

DRM token vending system architecture overview

A player requests playback and the Entitlement Gateway first checks that the account has an active subscription and a valid session, a cheap check backed by a short-lived cache in front of the billing database. It forwards a passing request to the License Server, which is the core of the system. The License Server checks the Device Registry to either confirm an already-bound device or bind a new one (rejecting the request if the account is already at its device cap), checks the Revocation Service to make sure the account, device, or any prior license for this content is not on the revoked list, then asks the Key Management Service to sign a compact Playback Authorization Token (PAT) with a 90-second TTL. In parallel, it proxies a key request to the upstream DRM provider (Widevine or FairPlay) to obtain the actual wrapped content key for this device’s CDM. Both the PAT and the wrapped key are returned to the player in one response.

From there, the player attaches the PAT to every subsequent segment request it sends to the CDN. The CDN Edge Verifier, running as a small function at each of 200+ points of presence, checks the PAT’s signature, expiry, and membership in a locally replicated revocation bloom filter, entirely without contacting the origin. If a subscription lapses or an account gets flagged mid-stream, the Revocation Service pushes a delta to every edge PoP over a long-lived streaming connection, and the player’s next renewal request (roughly every 70 seconds, 20 seconds before the current PAT expires) gets rejected at the License Server, which re-checks entitlement on every renewal rather than trusting the original grant indefinitely.

Data flows in two distinct loops that run at very different frequencies: a slow, expensive issuance loop that touches the Device Registry, the Revocation Service, the Key Management Service, and an external DRM provider, and a fast, cheap edge verification loop that touches nothing but a local signature check and a local bloom filter, half a million times a second.

Key Insight

The single most important architectural decision is decoupling token issuance (slow, centralized, security-heavy) from token verification (fast, local, stateless). Revocation does not need to synchronously reach every edge before the next request - it only needs to reach the edge before the next renewal, and the token’s own short TTL absorbs the gap. This is what lets 500,000 verifications/sec add zero load to any central system.

Component Deep Dives

The Entitlement Gateway

This component’s job is to answer “does this account currently have an active subscription” cheaply enough to run in front of every playback request, without hammering the billing database.

A smart engineer’s first instinct is to check subscription status against the billing database on every playback request, since that is the one place status is guaranteed to be fresh. That works at low volume and falls apart the moment License Server renewals climb into the tens of thousands per second, because billing databases are built for transactional correctness on payment events, not for read volume shaped like a video CDN. We front the billing database with a short-lived cache (5 seconds) keyed by user ID. A 5-second staleness window is a deliberate tradeoff: it is short enough that a cancellation becomes visible on the very next renewal cycle, and long enough to cut effective read volume against billing by more than 95% during sustained playback.

# Entitlement Gateway: cached subscription status check in front of the License Server
# Demonstrates: short-TTL read-through cache, fail-closed on cache and DB miss
import time

ENTITLEMENT_CACHE_TTL_SECONDS = 5

class EntitlementLapsed(Exception):
    pass

def check_entitlement(cache, billing_db, user_id: int) -> str:
    cache_key = f"entitlement:{user_id}"
    status = cache.get(cache_key)
    if status is None:
        row = billing_db.fetch_subscription(user_id)
        if row is None:
            raise EntitlementLapsed(user_id)
        status = row["status"]
        cache.set(cache_key, status, ex=ENTITLEMENT_CACHE_TTL_SECONDS)

    if status not in ("active", "trial"):
        raise EntitlementLapsed(user_id)
    return status

The Gateway is stateless and scales horizontally behind a load balancer, same as any other request-routing tier. It does not itself decide anything about devices or tokens - it exists purely to reject the overwhelming majority of unauthorized or lapsed requests before they reach the more expensive License Server logic.

Watch Out

A 5-second entitlement cache means a fraud team’s “kill this account now” action is not instant at the Gateway layer - it takes effect on the account’s next cache refresh. For high-severity cases (confirmed stolen card, active piracy), the Revocation Service bypasses this cache entirely and pushes directly into the token-level revocation list, which the edge checks on every request regardless of the Gateway’s cache state.

The License Server

This component’s job is to be the single place that turns a passed entitlement check into a signed, short-lived proof of authorization, and nothing else touches the signing key.

The instinctive design is to make the License Server a thin pass-through that just calls the DRM vendor’s license server directly and relays the response. The problem is that Widevine and FairPlay licenses are scoped to the DRM vendor’s own policy engine (output resolution, HDCP requirements, offline window), which is real and necessary, but it is not the same thing as your own business’s entitlement and device-binding rules, and the CDN cannot inspect an opaque vendor license to make a fast per-segment decision anyway. The License Server exists specifically to wrap vendor DRM with your own lightweight, edge-checkable authorization layer.

License server internals showing entitlement check, device binding, signing, and DRM key proxy

The PAT itself is a fixed-width binary structure, not a JSON web token. At 51 bytes of body plus a 64-byte Ed25519 signature, it base64url-encodes to roughly 155 characters, well under half the size of a typical JWT, which matters when this token rides on every single segment request as a query parameter or header.

# License Server: pack and sign a Playback Authorization Token (PAT)
# Demonstrates: fixed-width binary encoding, Ed25519 signing, compact token format
import struct
import time
import uuid
import hashlib
import base64
from nacl.signing import SigningKey

PAT_VERSION = 1
PAT_TTL_SECONDS = 90
PAT_BODY_FORMAT = "!B16sQ8sQBBII"  # version, license_id, user_id, device_hash, content_id, res, hdcp, iat, exp

def pack_pat_body(license_id: uuid.UUID, user_id: int, device_id: str, content_id: int,
                   max_resolution: int, hdcp_required: bool, now: int) -> bytes:
    device_hash = hashlib.sha256(device_id.encode()).digest()[:8]
    return struct.pack(
        PAT_BODY_FORMAT,
        PAT_VERSION,
        license_id.bytes,
        user_id,
        device_hash,
        content_id,
        max_resolution,
        1 if hdcp_required else 0,
        now,
        now + PAT_TTL_SECONDS,
    )

def issue_pat(signing_key: SigningKey, *, license_id, user_id, device_id, content_id,
              max_resolution, hdcp_required) -> str:
    now = int(time.time())
    body = pack_pat_body(license_id, user_id, device_id, content_id, max_resolution, hdcp_required, now)
    signature = signing_key.sign(body).signature  # 64-byte Ed25519 signature over the body
    token = body + signature
    return base64.urlsafe_b64encode(token).decode().rstrip("=")

Think of the signature the way a mint stamps a coin: anyone can verify the stamp is genuine using the publicly known die pattern, but only the mint holds the die itself. The CDN edge verifies PATs using the License Server’s public key, which it can cache indefinitely, while the private signing key never leaves the Key Management Service. Ed25519 was chosen over RSA specifically because its signatures are small (64 bytes versus 256+) and its verification is fast enough to run as a hot-path operation at every edge node without a hardware accelerator.

Real World

Amazon Prime Video and Netflix both operate a broker layer in front of the standard DRM vendors for exactly this reason - the vendor’s license governs the content-key handshake inside the player’s secure pipeline, while a separate, much lighter authorization signal governs whether the CDN should serve segments at all. Splitting these concerns is standard practice once a platform passes a few million subscribers, precisely because the CDN cannot inspect an opaque vendor license to make a fast decision.

Device Binding and Registry

This component’s job is to make the cost of sharing one subscription across many households higher than the cost of just buying more subscriptions.

The tempting shortcut is to treat “verified login” as sufficient proof of legitimate access, on the theory that password sharing is a business problem, not an engineering one. In practice, unrestricted concurrent device access is precisely what makes password sharing costless, and DRM systems are one of the few places where the platform can enforce a real technical limit without touching the login flow at all. We bind each issued license to a device fingerprint hash (derived from hardware identifiers, TLS fingerprint, and platform metadata) and cap the number of concurrently active devices per account, independent of how many times the account has logged in.

# Device binding: atomic cap enforcement using a Redis sorted set per account
# Demonstrates: Lua script for atomic check-and-bind, sliding device activity window
import time

MAX_DEVICES_PER_ACCOUNT = 4
DEVICE_ACTIVE_WINDOW_SECONDS = 60 * 60 * 24 * 30  # a device not seen in 30 days falls out of the count

BIND_DEVICE_SCRIPT = """
local key = KEYS[1]
local device_id = ARGV[1]
local now = tonumber(ARGV[2])
local max_devices = tonumber(ARGV[3])
local window = tonumber(ARGV[4])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local existing = redis.call('ZSCORE', key, device_id)
if existing then
  redis.call('ZADD', key, now, device_id)
  return 1
end

local count = redis.call('ZCARD', key)
if count < max_devices then
  redis.call('ZADD', key, now, device_id)
  return 1
end

return 0
"""

def try_bind_device(redis_client, user_id: int, device_id: str) -> bool:
    key = f"devices:{user_id}"
    now = int(time.time())
    result = redis_client.eval(
        BIND_DEVICE_SCRIPT,
        keys=[key],
        args=[device_id, now, MAX_DEVICES_PER_ACCOUNT, DEVICE_ACTIVE_WINDOW_SECONDS],
    )
    return bool(result)

Running the check-and-bind as a single Lua script closes a race condition that would otherwise exist: without atomicity, two devices hitting the cap simultaneously could both read “3 of 4 devices used” and both get bound, silently exceeding the cap. The 30-day rolling window matters just as much as the cap itself - without it, a family that gets a new phone every couple of years would eventually accumulate stale device entries and get permanently locked out of their own cap, which turns a security control into a support ticket generator.

Watch Out

Fingerprinting alone is not a strong identity signal - hardware IDs can be spoofed and TLS fingerprints can collide across devices behind the same corporate proxy. Treat the device fingerprint as one signal among several (IP reputation, account velocity, geographic plausibility), not as a cryptographic guarantee. A device binding system that trusts fingerprints absolutely will eventually block legitimate users and let sophisticated abuse through at the same time.

Revocation List Propagation

This component’s job is to get the fact that “this license, device, or account is no longer allowed” from a single write in a central database to every CDN edge PoP worldwide, fast enough that the token’s own TTL becomes the real bottleneck, not propagation.

The naive design has each edge PoP poll a central revocation endpoint on a timer. Polling forces a tradeoff between propagation speed and request volume: poll every second across 200+ PoPs and you have created a steady 200 requests/sec against a central service that scales with PoP count, not with revocation event volume; poll less often and you have directly widened your exposure window. We instead push revocation events over a long-lived streaming connection from each PoP to the Revocation Service, so new events arrive within a second of being written, with polling reserved only as a periodic full-resync safety net rather than the primary mechanism.

// Revocation propagation stream contract between the central service and each edge PoP
syntax = "proto3";
package drm.revocation.v1;

enum RevocationSubjectType {
  SUBJECT_UNKNOWN = 0;
  SUBJECT_LICENSE = 1;
  SUBJECT_DEVICE = 2;
  SUBJECT_USER = 3;
}

message RevocationEvent {
  string event_id = 1;
  RevocationSubjectType subject_type = 2;
  string subject_id = 3;   // license_id, device_id, or user_id depending on subject_type
  string reason = 4;       // cancel, chargeback, password_change, abuse_flag
  int64 occurred_at_unix = 5;
}

message StreamRevocationsRequest {
  int64 since_unix = 1;
  string pop_id = 2;
}

service RevocationFeed {
  // Long-lived streaming RPC; each edge PoP subscribes once and receives a
  // continuous feed of deltas plus periodic keepalives.
  rpc StreamRevocations(StreamRevocationsRequest) returns (stream RevocationEvent);
}
Revocation events streaming from the central service to regional relays and edge PoPs

Each edge PoP applies incoming events directly into an in-memory bloom filter (covered in detail in the algorithms section below), which is what actually gets checked on every segment request. A regional relay tier sits between the central Revocation Service and individual PoPs so that a single stream fan-out does not have to maintain 200+ direct connections from one origin process - relays batch and re-broadcast within their region.

Real World

Cloudflare’s Workers KV and Fastly’s Edge Dictionaries both solve a version of this same problem - propagating small, frequently-updated key sets to hundreds of edge locations with eventual consistency measured in seconds rather than the tens of minutes a full CDN cache purge can take. The pattern of “stream deltas, periodically resync in full” is the same one both platforms converged on independently for exactly this class of problem.

CDN-Level Entitlement Check

This component’s job is to answer “should this segment be served” in single-digit milliseconds, at every one of 500,000 requests per second globally, using nothing but what is already local to the PoP.

The mistake a smart engineer makes here is assuming the edge needs to call back to the License Server or a shared database to be sure. That assumption alone would make the whole design pointless - if verification required a network hop, we would not have gained anything over checking entitlement directly against origin. The edge verifier instead does everything locally: it checks the PAT’s Ed25519 signature against a cached public key, checks the expiry timestamp against the local clock, and checks the license ID against a local bloom filter replica.

// CDN edge verifier: fully local PAT validation, no origin round trip
// Demonstrates: signature check, expiry check, bloom filter revocation check
package edge

import (
	"crypto/ed25519"
	"encoding/base64"
	"encoding/binary"
	"errors"
	"time"
)

const patBodyLen = 51
const patSigLen = 64

var ErrBadSignature = errors.New("pat: invalid signature")
var ErrExpired = errors.New("pat: expired")
var ErrRevoked = errors.New("pat: revoked")

type PAT struct {
	LicenseID     [16]byte
	UserID        uint64
	DeviceHash    [8]byte
	ContentID     uint64
	MaxResolution uint8
	HDCPRequired  bool
	IssuedAt      uint32
	ExpiresAt     uint32
}

func VerifyPAT(token string, publicKey ed25519.PublicKey, revoked *BloomFilter, now time.Time) (*PAT, error) {
	raw, err := base64.RawURLEncoding.DecodeString(token)
	if err != nil || len(raw) != patBodyLen+patSigLen {
		return nil, ErrBadSignature
	}
	body, sig := raw[:patBodyLen], raw[patBodyLen:]
	if !ed25519.Verify(publicKey, body, sig) {
		return nil, ErrBadSignature
	}

	pat := parsePATBody(body)
	if uint32(now.Unix()) > pat.ExpiresAt+2 { // 2s clock-skew tolerance
		return nil, ErrExpired
	}
	if revoked.MightContain(pat.LicenseID[:]) {
		return nil, ErrRevoked
	}
	return pat, nil
}

func parsePATBody(b []byte) *PAT {
	pat := &PAT{}
	copy(pat.LicenseID[:], b[1:17])
	pat.UserID = binary.BigEndian.Uint64(b[17:25])
	copy(pat.DeviceHash[:], b[25:33])
	pat.ContentID = binary.BigEndian.Uint64(b[33:41])
	pat.MaxResolution = b[41]
	pat.HDCPRequired = b[42] == 1
	pat.IssuedAt = binary.BigEndian.Uint32(b[43:47])
	pat.ExpiresAt = binary.BigEndian.Uint32(b[47:51])
	return pat
}

A verification failure always fails closed: a bad signature, an expired token, or a hit in the revocation filter all result in a rejected segment request, never a served one. This is a deliberate asymmetry - the cost of an occasional false rejection (handled by the player retrying with a freshly renewed token) is far lower than the cost of ever serving content on a forged or revoked token.

Watch Out

If a PoP loses its streaming connection to the Revocation Service, do not fail open and skip the revocation check - that turns a network blip into a security bypass. Keep serving against the last-known bloom filter state instead, which is exactly what bounds worst-case exposure to the PAT’s own TTL rather than to however long the PoP stays disconnected.

The Renewal Loop

This component’s job is to let a subscriber keep watching for hours without ever holding a token that is valid for more than 90 seconds at a time.

The obvious failure mode of short-lived tokens is that they force constant re-issuance, and if re-issuance meant repeating the full entitlement check, device lookup, and DRM key exchange every 90 seconds for 3 million concurrent streams, the License Server tier would need to be sized for the full issuance cost at renewal volume, which is far larger than actual new-session volume. The renewal path is intentionally lighter than initial issuance: it reuses the same license_id, skips the device binding check entirely (the device was already bound at issuance and does not change mid-stream), and re-checks only entitlement and revocation status.

# Renewal endpoint: cheaper re-issuance path, same license_id, fresh expiry window
# Demonstrates: bounded re-check cost, explicit rejection on lapsed entitlement or revocation
import time

RENEW_BEFORE_EXPIRY_SECONDS = 20

class LicenseRevoked(Exception):
    pass

def renew_pat(cache, billing_db, revocation_index, signing_key, *, old_pat: dict, device_id: str) -> str:
    status = check_entitlement(cache, billing_db, old_pat["user_id"])  # 5s-cached, see Entitlement Gateway
    if revocation_index.is_revoked(old_pat["license_id"]):
        raise LicenseRevoked(old_pat["license_id"])

    return issue_pat(
        signing_key,
        license_id=old_pat["license_id"],       # renewal keeps the same identity, only the window moves
        user_id=old_pat["user_id"],
        device_id=device_id,
        content_id=old_pat["content_id"],
        max_resolution=old_pat["max_resolution"],
        hdcp_required=old_pat["hdcp_required"],
    )

def should_renew_now(pat_expires_at: int, now: int) -> bool:
    return (pat_expires_at - now) <= RENEW_BEFORE_EXPIRY_SECONDS

The player triggers a renewal 20 seconds before its current PAT expires, giving a comfortable margin for network latency and retries before the old token actually stops working. Because the same license_id carries forward across every renewal, a revocation check only ever has to look up one identifier for the entire lifetime of a viewing session, regardless of how many times it has renewed.

Key Insight

Treating renewal as a continuation of the same license, not a new grant, is what keeps the security guarantee and the performance budget aligned. If every renewal minted a brand-new license_id, the Device Registry and Revocation Service would need to track an unbounded, ever-growing set of identifiers per viewing session instead of exactly one.

Data Model

The system’s durable state splits cleanly into three tables: who is entitled, which devices are bound to them, and which licenses have been granted or revoked.

-- Subscription status, the source of truth the Entitlement Gateway caches
CREATE TABLE subscriptions (
    user_id             BIGINT PRIMARY KEY,
    plan                TEXT NOT NULL,
    status              TEXT NOT NULL
        CHECK (status IN ('active', 'trial', 'past_due', 'canceled')),
    current_period_end  TIMESTAMPTZ NOT NULL,
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Devices bound to an account, enforced against MAX_DEVICES_PER_ACCOUNT
CREATE TABLE devices (
    device_id           TEXT PRIMARY KEY,
    user_id             BIGINT NOT NULL REFERENCES subscriptions(user_id),
    fingerprint_hash    TEXT NOT NULL,
    platform            TEXT NOT NULL,
    first_seen_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_seen_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    is_active           BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX ON devices (user_id, last_seen_at DESC) WHERE is_active;

-- One row per issued license; renewals update last_renewed_at, they never insert
CREATE TABLE license_grants (
    license_id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id             BIGINT NOT NULL REFERENCES subscriptions(user_id),
    device_id           TEXT NOT NULL REFERENCES devices(device_id),
    content_id          BIGINT NOT NULL,
    max_resolution      SMALLINT NOT NULL,
    hdcp_required       BOOLEAN NOT NULL DEFAULT FALSE,
    issued_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_renewed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    revoked_at           TIMESTAMPTZ,
    revoke_reason        TEXT
) PARTITION BY RANGE (issued_at);

CREATE INDEX ON license_grants (user_id, issued_at DESC);
CREATE INDEX ON license_grants (revoked_at) WHERE revoked_at IS NOT NULL;

-- Append-only revocation log, the tail of this table feeds the propagation stream
CREATE TABLE revocation_events (
    event_id             BIGSERIAL PRIMARY KEY,
    subject_type          TEXT NOT NULL CHECK (subject_type IN ('license', 'device', 'user')),
    subject_id             TEXT NOT NULL,
    reason                  TEXT NOT NULL,
    occurred_at             TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON revocation_events (occurred_at);

license_grants is partitioned by issued_at in weekly ranges. This matters for two reasons: writes are append-heavy and time-ordered, so range partitioning keeps each partition’s working set small and its indexes hot, and old partitions (beyond the 30-day abuse-investigation window) can be dropped outright instead of run through an expensive DELETE. Renewals update last_renewed_at on the existing row rather than inserting a new one, which keeps the table’s growth rate tied to actual new viewing sessions (roughly 96 million rows a day at our scale) instead of to renewal volume, which would be 30-40x larger.

revocation_events is deliberately small and append-only; it exists to feed the propagation stream and to answer “why was this revoked” during an investigation, not to be queried at request time. The hot revocation check at the edge never touches Postgres at all - it only ever reads the locally replicated bloom filter.

License record lifecycle from request through renewal to revocation or expiry

A license moves through four states over its life: requested (entitlement and device check), issued (PAT signed, DRM key fetched), renewing (a loop that can run for hours while a subscriber watches), and revoked or expired, which is the only state with no path back out.

Key Insight

Sharding license_grants and devices by hash(user_id) keeps every query that matters - “check this user’s devices,” “look up this user’s recent licenses” - on a single shard. The one query that would need a cross-shard fan-out, “find every license across the whole system for a given device fingerprint” (used in abuse investigations), is deliberately routed through a separate analytical store rather than forced onto the hot operational schema.

Key Algorithms and Protocols

PAT Encoding and Signature Verification

The PAT format trades the flexibility of a JSON-based token for a fixed-width binary layout that can be parsed with a single struct.unpack call instead of a JSON parser, and verified with one constant-time elliptic-curve operation.

# PAT parsing at verification time - O(1) field extraction, explicit version handling
import struct

PAT_BODY_FORMAT = "!B16sQ8sQBBII"
PAT_BODY_LEN = struct.calcsize(PAT_BODY_FORMAT)  # 51 bytes
SKEW_TOLERANCE_SECONDS = 2

def parse_pat_body(body: bytes) -> dict:
    if len(body) != PAT_BODY_LEN:
        raise ValueError("malformed PAT body")
    (version, license_id, user_id, device_hash, content_id,
     max_resolution, hdcp_required, issued_at, expires_at) = struct.unpack(PAT_BODY_FORMAT, body)
    if version != 1:
        raise ValueError(f"unsupported PAT version {version}")
    return {
        "license_id": license_id,
        "user_id": user_id,
        "device_hash": device_hash,
        "content_id": content_id,
        "max_resolution": max_resolution,
        "hdcp_required": bool(hdcp_required),
        "issued_at": issued_at,
        "expires_at": expires_at,
    }

def is_within_validity(expires_at: int, now: int) -> bool:
    return now <= expires_at + SKEW_TOLERANCE_SECONDS

Parsing is O(1) regardless of load, since the format never varies in size, and the version byte gives a forward-compatible escape hatch for a future PAT layout without breaking already-deployed edge verifiers. Ed25519 signature verification itself is a constant-time public-key operation, typically well under 100 microseconds on commodity hardware, which does not scale with the number of tokens in circulation because each verification only involves the token’s own bytes and a cached public key.

Key Insight

The property that makes edge verification free at scale is that it requires zero external state lookups beyond data that is already local to the PoP: a cached public key and a local bloom filter. Five hundred thousand verifications a second add precisely zero load to any central system, because nothing central is ever consulted on the request path.

Revocation Bloom Filter

A bloom filter answers “is this license possibly revoked” using a fixed-size bit array instead of a growing set, at the cost of occasionally saying “maybe” when the true answer is “no.” It is the data structure equivalent of a bouncer with a short list of banned names memorized imperfectly - fast to check, sometimes overly cautious, but never lets a truly banned name slip through.

# Revocation bloom filter for edge-local checks
# Demonstrates: optimal size/hash-count calculation, double hashing (Kirsch-Mitzenmacher)
import hashlib
import math

class RevocationBloomFilter:
    def __init__(self, expected_entries: int, false_positive_rate: float = 0.001):
        self.m = self._optimal_bits(expected_entries, false_positive_rate)
        self.k = self._optimal_hashes(self.m, expected_entries)
        self.bits = bytearray((self.m + 7) // 8)

    @staticmethod
    def _optimal_bits(n: int, p: float) -> int:
        return int(math.ceil(-(n * math.log(p)) / (math.log(2) ** 2)))

    @staticmethod
    def _optimal_hashes(m: int, n: int) -> int:
        return max(1, round((m / n) * math.log(2)))

    def _indexes(self, item: bytes):
        h1 = int.from_bytes(hashlib.md5(item).digest()[:8], "big")
        h2 = int.from_bytes(hashlib.sha1(item).digest()[:8], "big")
        for i in range(self.k):
            yield (h1 + i * h2) % self.m

    def add(self, item: bytes) -> None:
        for idx in self._indexes(item):
            self.bits[idx // 8] |= (1 << (idx % 8))

    def might_contain(self, item: bytes) -> bool:
        return all(self.bits[idx // 8] & (1 << (idx % 8)) for idx in self._indexes(item))

Both add and might_contain run in O(k) time, independent of how many entries the filter holds, and space is O(m) bits, fixed at construction time. Sized for 200,000 concurrently revoked entries at a 0.1% false-positive rate, the filter needs roughly 2.9 million bits, about 360KB, small enough to replicate to every edge PoP without a second thought. The edge case that matters is what a false positive actually costs: a wrongly-flagged, still-valid license gets one segment request denied, the player retries against a freshly renewed token from the License Server, and the false positive resolves itself on the next renewal. A false negative, which the bloom filter’s math guarantees can never happen, would be the actually dangerous failure mode.

Key Insight

The one property that makes a bloom filter safe for this use case is that it can only produce false positives, never false negatives. A “maybe revoked” answer costs one denied request and a retry. A missed revocation would mean serving content to an account that should have been cut off, which is the one outcome the whole system exists to prevent.

Scaling and Performance

The system scales along two independent axes: the License Server tier, sharded by hash(user_id) to spread issuance and renewal load, and the CDN Edge Verifier, which scales with PoP count and carries zero shared state with the License Server tier at all.

Capacity Estimation:

Given:
  - 40,000,000 active subscribers
  - 3,000,000 concurrent streams at peak
  - PAT: 115 bytes signed (51B body + 64B signature), 90s TTL, renewed ~70s
  - Average segment length: 6 seconds
  - 200+ CDN edge PoPs

License Server tier (issuance + renewal):
  Renewal QPS: 3,000,000 / 70s ~= 42,857/sec sustained
  Issuance QPS: 3,000,000 / 2,700s (45min avg session) ~= 1,111/sec sustained
  Sustained total: ~44,000 ops/sec
  Peak (live-event ramp, high issuance burst layered on renewal load): ~65,000 ops/sec
  At ~5,000 ops/sec per shard: 10 shards for sustained, 14 shards for peak headroom

Edge verification tier:
  3,000,000 streams / 6s segment ~= 500,000 verifications/sec globally
  Spread across 200 PoPs: ~2,500/sec/PoP average, hot metro PoPs up to ~25,000/sec
  Each verification: 1 Ed25519 check (~100us) + 1 bloom filter check (O(k), ~1us) - no network call

Revocation propagation:
  Each event: ~40 bytes on the wire
  Worst case mass-cancellation burst: 50,000 events in a short window ~= 2MB total - trivial bandwidth
  Per-PoP bloom filter footprint: ~360KB for 200,000 entries at 0.1% false-positive rate

Storage:
  license_grants grows only on new issuance (renewals update in place): ~96M rows/day
  At ~120 bytes/row: ~11.5GB/day, pruned after a 30-day investigation window
License server tier scaling from 10 to 14 shards for a live-event traffic spike

The read-to-write ratio at the edge is effectively infinite in the sense that matters most for capacity planning: every one of the 500,000 verifications a second is a pure read against local state, and the only writes that ever reach an edge PoP are small revocation deltas measured in tens of bytes. We do not add a caching tier between the License Server and its Postgres backing store beyond the entitlement cache already described, because the write-heavy part of this system (renewals) never touches Postgres in the common case at all - it only touches the entitlement cache and the revocation index, both of which are already in-memory.

Real World

Disney+ and other large streaming platforms have published infrastructure retrospectives describing exactly this kind of “spike shard ahead of a known event” pattern for password-and-license-adjacent services, scaling their entitlement tiers up before major live sports or finale releases rather than reactively during the traffic ramp. The same discipline applies here: the License Server tier is capacity-planned against the calendar, not just against historical averages.

Failure Modes and Recovery

FailureDetectionImpactRecovery
License Server instance crashLoad balancer health check failsIn-flight issuance/renewal requests to that instance failLB routes new requests to healthy instances; server is stateless, client retries succeed immediately
Edge PoP loses revocation stream connectiongRPC stream heartbeat missed by the regional relayPoP keeps serving against its last-known bloom filterPoP reconnects and pulls a full resync; worst-case exposure still bounded by the 90s PAT TTL
Upstream DRM key server (Widevine/FairPlay) outageProxy calls to the vendor time out or errorCannot fetch the wrapped content key for new issuance, even though PAT issuance itself is healthyCircuit breaker opens; the specific content request fails cleanly instead of hanging, existing renewals are unaffected
Clock skew between License Server and edge PoPsEdge rejects tokens with an iat far in the future, or accepts one slightly past expPremature 403s or a marginally extended exposure windowNTP-synced infrastructure, plus a small explicit skew tolerance (2s) baked into the verifier
Device fingerprint collision or spoofingAnomaly detection flags one fingerprint bound to an unusual number of accountsDevice binding cap can be bypassed, masking credential sharingLayer fingerprint with IP reputation and account velocity signals; rate-limit binding attempts per fingerprint
Mass-cancellation event floods revocation_eventsWrite latency spike on the revocation table, propagation lag alarmsPropagation delay grows beyond the 5-second targetBatch and queue revocation writes through Kafka to decouple ingestion from fan-out; partition the table by time
Watch Out

The most common operational mistake is lengthening the PAT TTL “temporarily” to reduce renewal load during a traffic spike. The TTL is a security control, not a caching knob - every extra second added to it is an extra second of guaranteed exposure for any token that leaks or should have been revoked. Solve renewal load with more License Server shards, never with a longer TTL.

Comparison of Approaches

ApproachLatencyComplexityFailure ModeBest Fit
Long-lived JWT, no per-segment checkUnder 1ms (local verify only)LowCannot revoke before natural expiry, which can be hoursLow-value or free-tier content where revocation speed does not matter
Origin round-trip check per segment50-150ms added per request under real-world fan-outMediumOrigin becomes a single point of failure and a bottleneck at scaleVery low concurrency premium events (single pay-per-view broadcasts) needing perfect real-time entitlement
Short-lived PAT + edge-local bloom filter (this design)Sub-5ms p99, fully local at the edgeHigh (propagation infra, key rotation, filter tuning)Bounded exposure window set by TTL, even if propagation itself is degradedMass-market subscription VOD/live at tens of millions of subscribers
Sticky, PoP-pinned license cacheLow within a pinned PoP once warmMediumCache is PoP-local and inconsistent; a roaming client hitting a different PoP sees stale stateSmall, single-region deployments with stable client-to-PoP affinity
Raw vendor DRM license only, no app-layer wrapperDepends entirely on the DRM vendor’s own infrastructureLow (call the vendor directly)Revocation and device-binding policy limited to whatever the vendor’s license duration supportsSmall catalogs that do not need custom entitlement logic beyond vendor policy defaults

For a subscription platform at tens of millions of users with a real revocation SLA, the short-lived PAT with edge-local verification is the right call: it is the only option here that combines sub-5ms edge latency with a revocation exposure window that is bounded and predictable regardless of what else is failing elsewhere in the system. The origin-round-trip approach gets real-time correctness right but cannot survive the request volume; long-lived JWTs and raw vendor licenses get latency right but give up any meaningful control over how fast a canceled subscription actually stops working, which is the entire point of building this system in the first place.

Key Takeaways

  • Decouple issuance from verification: token issuance is slow and centralized by necessity; verification must be fast and local, or the whole design collapses under request volume.
  • Short TTLs are the real revocation mechanism: propagation infrastructure narrows the average exposure window, but the token’s own TTL is what guarantees the worst case.
  • Renewal is a continuation, not a new grant: keeping the same license_id across renewals keeps revocation lookups to one identifier per viewing session instead of an unbounded, growing set.
  • Bloom filters trade a bounded false-positive rate for O(1) local checks: the asymmetry only works because the false-negative rate is mathematically zero, which is the one property this use case cannot compromise on.
  • Device binding targets sharing, not just theft: a hard concurrent-device cap is one of the few controls that directly limits password sharing without touching the login flow at all.
  • Not every write needs the same durability or freshness: entitlement can tolerate a 5-second cache; revocation cannot tolerate more than a few seconds of propagation delay; the two have deliberately different consistency budgets.
  • The CDN edge and the DRM vendor’s CDM solve different problems: the vendor’s license governs the content-key handshake inside the player; the PAT governs whether the CDN serves bytes at all. Conflating the two removes the CDN’s ability to make a fast, independent decision.
  • Fail closed, always: a signature failure, an expired token, or a disconnected revocation stream must all default to denying the request, never to serving it.

The counter-intuitive lesson is that the hardest part of this system is not the cryptography or even the bloom filter math, it is accepting that instant revocation is a marketing simplification. What actually gets built is a token with an exposure window measured in seconds, backed by propagation infrastructure that makes the average case much faster than the worst case. Chasing literal instant revocation across a globally distributed edge would mean giving up the local-only verification that makes the system affordable to run at half a million checks a second in the first place.

Frequently Asked Questions

Q: Why not just make the vendor DRM license (Widevine, FairPlay) itself short-lived instead of building a separate PAT layer?

A: The vendor’s DRM license governs the content-key handshake that happens inside the player’s secure media pipeline, which the CDN edge cannot see or participate in at all. Even a perfectly short-lived vendor license would not give the CDN anything to check on a segment request, because the CDN is not a party to that handshake. The PAT layer exists specifically to give the CDN its own independent, fast, locally verifiable signal, separate from whatever policy the vendor’s license enforces on the client side.

Q: Why not check revocation against Postgres directly at the edge instead of maintaining a bloom filter?

A: At 500,000 verifications a second globally, a direct database check per request would require either an enormous globally-replicated database tier or accept latency far outside the 5ms budget. A bloom filter turns “is this revoked” into a local, in-memory O(k) operation with no network dependency, at the cost of accepting a small, bounded false-positive rate that resolves itself on the next renewal.

Q: Why not just set the PAT TTL to something longer, like an hour, to cut renewal load?

A: The TTL is the hard upper bound on how long a leaked or should-be-revoked token remains usable. Lengthening it to save on renewal traffic directly weakens the system’s core security guarantee. Renewal load is solved by adding License Server shards, which is a straightforward horizontal scaling problem; a longer TTL solves nothing and creates a security regression that is easy to introduce under pressure and easy to forget to revert.

Q: How is this different from just using signed CDN URLs with an expiry, like S3 presigned URLs?

A: Signed URLs with expiry solve a narrower problem: they prove a URL was generated by someone holding a signing key, at some point before expiry. They do not carry device binding, do not get checked against a live revocation list, and typically are not renewed transparently mid-session. A PAT is a signed URL’s more opinionated cousin, built specifically for the entitlement, device-binding, and instant-cutoff requirements that streaming video has and generic object storage access does not.

Q: What happens to a family with four bound devices when someone buys a new phone?

A: The 30-day rolling activity window in the Device Registry means a device that has not been used in 30 days ages out of the count automatically, freeing a slot without any manual intervention. For a genuinely urgent case (lost phone, immediate replacement), the account settings expose an explicit “remove this device” action that clears a binding immediately rather than waiting on the window.

Q: How do you avoid a bloom filter growing stale or oversized as revoked entries accumulate over time?

A: Revoked entries are only relevant for as long as a license bearing that ID could still be circulating, which is bounded by the 90-second PAT TTL plus a safety margin. The Revocation Service periodically rebuilds and re-broadcasts a fresh, right-sized bloom filter containing only entries revoked within roughly the last few minutes, rather than accumulating every revocation ever issued, which keeps both the false-positive rate and the per-PoP memory footprint stable regardless of how long the system has been running.

Interview Questions

Q: Design the token format for a system that needs to be verified hundreds of thousands of times a second at globally distributed edge locations, with zero calls back to a central system. What tradeoffs would you make?

Expected depth: Discuss why a fixed-width binary format beats a JSON-based token (JWT) for parsing cost and size at this volume, why Ed25519 is preferred over RSA for signature size and verification speed, and the tradeoff of opaque server-verified tokens versus self-contained claims. Cover what has to be embedded in the token itself (expiry, identity, scope) versus what can be checked against local edge state (a revocation bloom filter) versus what must never be trusted to the token alone.

Q: A subscriber cancels their account mid-stream during a live sports event. Walk through exactly what happens from the moment of cancellation to the moment their stream actually stops.

Expected depth: Trace the full path: billing emits a subscription-state-changed event, the Revocation Service writes a revocation_events row and streams it to edge relays, edge PoPs apply the delta into their local bloom filter typically within 5 seconds, but the subscriber’s current PAT (already issued, up to 90 seconds of validity remaining) keeps working at the edge until either it naturally expires or the renewal request at the License Server rejects it based on the now-lapsed entitlement cache. Quantify the actual worst-case window as the sum of these bounded delays, not “instant.”

Q: How would you detect and mitigate a coordinated abuse pattern where one set of credentials is being used simultaneously across dozens of devices, cycling through the 4-device cap by repeatedly unbinding and rebinding?

Expected depth: Discuss anomaly detection signals beyond the raw device count: binding/unbinding velocity, geographic implausibility (devices binding from distant regions within minutes of each other), IP reputation, and correlating device fingerprint churn against account tenure. Cover the tradeoff between tightening the rebind cooldown (which inconveniences legitimate device upgrades) and leaving it loose (which is easy to abuse), and where a human review queue fits into an otherwise fully automated system.

Q: The edge verification path is designed to never call back to origin. What happens if the Ed25519 public key needs to be rotated, and how do you avoid a coordinated, risky “flag day” rollout across 200+ PoPs?

Expected depth: Discuss maintaining a small set of currently-valid public keys (current plus one previous) rather than a single key, distributing key updates through the same propagation channel used for revocation deltas, and a rollover window where PATs signed with either key verify successfully. Cover how this avoids a hard cutover where any PoP that has not yet received the new key starts rejecting every valid token signed with it.

Q: Your capacity estimate assumes segment requests are the dominant edge verification load. How would the design change for a platform that also needs to protect live, low-latency (sub-2-second) streams where segments are much smaller and more frequent?

Expected depth: Discuss how a much shorter segment interval multiplies edge verification QPS proportionally, and whether per-segment verification is still the right granularity versus verifying less frequently (for example, once per chunked-transfer connection) at the cost of a coarser revocation grain. Cover the latency budget tension: low-latency live streaming has less tolerance for even a single failed verification triggering a stall, so retry and renewal timing need to be tuned tighter than for on-demand video.

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article