Skip to main content

iOS AVPlayer example

Play FairPlay-protected HLS in a native AVPlayer and acquire the license from Cloud DRM. This example uses AVContentKeySession and shows how to build the license request URL correctly.

The full, runnable project is on GitHub. The snippets below are taken from it.

GitHub example repository link

Prerequisites​

  • Xcode installed.
  • A physical iPhone or iPad. FairPlay playback doesn't work on the Simulator.
  • The following values from the Cloud Video Kit web console, under DRM → Configuration → License acquisition:
    • hlsURL – URL of the HLS manifest.
    • certificateURL – FairPlay certificate URL.
    • licenseURL – License Acquisition URL. This is the base of every license request.
    • brandGuid – your tenant id.
    • userToken – a JWT that authorizes the request. Your backend creates and signs it. For downloads, the token must allow persistence.

How license acquisition works​

FairPlay is handled through an AVContentKeySession. You register the asset as a content key recipient. Whenever AVFoundation needs a content key, it calls the delegate, which runs the FairPlay handshake:

certificate  ->  SPC (Server Playback Context)  ->  CKC (Content Key Context)

The #EXT-X-KEY tag in the HLS chunklist carries a skd:// key identifier that contains the KID and IV of the content key.

Build the license URL from your console URL, not from skd://

Don't send the license request to the skd:// identifier turned into https://. Use the License Acquisition URL from the web console as the base, and copy only the KID and IV from the skd:// identifier onto it. Send the user token in the x-drm-usertoken header, never in the URL.

Configure the Content Key session​

FairPlaySession owns an AVContentKeySession for FairPlay and registers the asset as a content key recipient, so AVFoundation routes key requests to the delegate.

let contentKeySession = AVContentKeySession(keySystem: .fairPlayStreaming)
contentKeySession.setDelegate(self, queue: .global(qos: .userInitiated))

func makeStreamingPlayerItem() -> AVPlayerItem {
let asset = AVURLAsset(url: configuration.hlsURL)
contentKeySession.addContentKeyRecipient(asset)
return AVPlayerItem(asset: asset)
}

PlayerViewModel builds an AVPlayer from that item and starts playback. AVFoundation asks for the content key while the item loads.

Build the license request URL​

The host and path come from your License Acquisition URL. The query is rebuilt with your tenant id plus only the KID and IV from the stream's skd:// identifier. Any other parameter on the configured URL is dropped, so a stale usertoken can't override the header.

func licenseRequestURL(for keyIdentifier: String) -> URL {
guard var components = URLComponents(url: licenseURL, resolvingAgainstBaseURL: false) else {
return licenseURL
}

// Start the query from the tenant id, then add only KID/IV from the stream.
var queryItems = [URLQueryItem(name: "brandGuid", value: brandGuid)]
if let keyComponents = URLComponents(string: keyIdentifier) {
for item in keyComponents.queryItems ?? [] where ["kid", "iv"].contains(item.name.lowercased()) {
queryItems.append(item)
}
}

// Assigning queryItems replaces any query already on the configured URL.
components.queryItems = queryItems
return components.url ?? licenseURL
}

The skd:// identifier is also the source of the content id used to generate the SPC. contentIdentifier(from:) returns both shapes: the raw string (for the license URL) and the bytes after skd:// (for the SPC).

static func contentIdentifier(from rawIdentifier: Any?) throws -> (raw: String, data: Data) {
let raw: String
if let value = rawIdentifier as? String {
raw = value
} else if let value = rawIdentifier as? URL {
raw = value.absoluteString
} else {
throw SampleError.invalidKeyIdentifier
}

let contentID = raw.hasPrefix("skd://") ? String(raw.dropFirst("skd://".count)) : raw
guard !contentID.isEmpty, let data = contentID.data(using: .utf8) else {
throw SampleError.invalidKeyIdentifier
}
return (raw, data)
}

Handle the content key request​

AVFoundation calls the delegate when it needs a content key. The delegate reads the identifier, generates the SPC, exchanges it for a CKC, and returns the CKC.

func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) {
Task {
do {
let identifier = try FairPlayConfiguration.contentIdentifier(from: keyRequest.identifier)
let certificate = try await licenseClient.fetchCertificate(configuration: configuration)

let spcData = try await makeSPC(
for: keyRequest,
certificate: certificate,
contentIdentifier: identifier.data
)

let ckcData = try await licenseClient.acquireLicense(
configuration: configuration,
keyIdentifier: identifier.raw,
spcData: spcData
)

keyRequest.processContentKeyResponse(
AVContentKeyResponse(fairPlayStreamingKeyResponseData: ckcData)
)
} catch {
keyRequest.processContentKeyResponseError(error)
}
}
}

The SPC is built by AVFoundation from the key request and the certificate:

keyRequest.makeStreamingContentKeyRequestData(
forApp: certificate,
contentIdentifier: identifier.data,
options: [AVContentKeyRequestProtocolVersionsKey: [1]]
) { spcData, error in /* ... */ }

Send the SPC and read the CKC​

acquireLicense posts the SPC to the URL from licenseRequestURL. The tenant id and user token go in headers.

var request = URLRequest(url: configuration.licenseRequestURL(for: keyIdentifier))
request.httpMethod = "POST"
request.httpBody = spcData
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
request.setValue(configuration.brandGuid, forHTTPHeaderField: "x-drm-brandGuid")
request.setValue(configuration.userToken, forHTTPHeaderField: "x-drm-usertoken")

let (data, response) = try await URLSession.shared.data(for: request)

The certificate response is accepted as raw DER, base64, or PEM. The CKC is returned as raw bytes, or as { "ckc": "<base64>" } when the request sets Accept: application/json.