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.
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).
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.
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.
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.
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.
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.
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);
}
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.
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.
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.
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.
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.
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.
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.
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
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.
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
| Failure | Detection | Impact | Recovery |
|---|---|---|---|
| License Server instance crash | Load balancer health check fails | In-flight issuance/renewal requests to that instance fail | LB routes new requests to healthy instances; server is stateless, client retries succeed immediately |
| Edge PoP loses revocation stream connection | gRPC stream heartbeat missed by the regional relay | PoP keeps serving against its last-known bloom filter | PoP reconnects and pulls a full resync; worst-case exposure still bounded by the 90s PAT TTL |
| Upstream DRM key server (Widevine/FairPlay) outage | Proxy calls to the vendor time out or error | Cannot fetch the wrapped content key for new issuance, even though PAT issuance itself is healthy | Circuit breaker opens; the specific content request fails cleanly instead of hanging, existing renewals are unaffected |
| Clock skew between License Server and edge PoPs | Edge rejects tokens with an iat far in the future, or accepts one slightly past exp | Premature 403s or a marginally extended exposure window | NTP-synced infrastructure, plus a small explicit skew tolerance (2s) baked into the verifier |
| Device fingerprint collision or spoofing | Anomaly detection flags one fingerprint bound to an unusual number of accounts | Device binding cap can be bypassed, masking credential sharing | Layer fingerprint with IP reputation and account velocity signals; rate-limit binding attempts per fingerprint |
Mass-cancellation event floods revocation_events | Write latency spike on the revocation table, propagation lag alarms | Propagation delay grows beyond the 5-second target | Batch and queue revocation writes through Kafka to decouple ingestion from fan-out; partition the table by time |
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
| Approach | Latency | Complexity | Failure Mode | Best Fit |
|---|---|---|---|---|
| Long-lived JWT, no per-segment check | Under 1ms (local verify only) | Low | Cannot revoke before natural expiry, which can be hours | Low-value or free-tier content where revocation speed does not matter |
| Origin round-trip check per segment | 50-150ms added per request under real-world fan-out | Medium | Origin becomes a single point of failure and a bottleneck at scale | Very 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 edge | High (propagation infra, key rotation, filter tuning) | Bounded exposure window set by TTL, even if propagation itself is degraded | Mass-market subscription VOD/live at tens of millions of subscribers |
| Sticky, PoP-pinned license cache | Low within a pinned PoP once warm | Medium | Cache is PoP-local and inconsistent; a roaming client hitting a different PoP sees stale state | Small, single-region deployments with stable client-to-PoP affinity |
| Raw vendor DRM license only, no app-layer wrapper | Depends entirely on the DRM vendor’s own infrastructure | Low (call the vendor directly) | Revocation and device-binding policy limited to whatever the vendor’s license duration supports | Small 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_idacross 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.