← Back to projects

Apple Music Edge API

A Cloudflare Worker that signs ES256 JWTs with the Web Crypto API, so my site can read the Apple Music API without shipping my developer private key to a browser.

Shipped Cloudflare Workers Web Crypto JWT / ES256

The problem

I wanted the homepage to show what I'm actually listening to, live. Every Apple Music API request needs a developer token — a JWT you sign yourself with a private key Apple issues as a .p8 file. Signing that in client-side JavaScript means handing the key to every visitor, so it has to happen somewhere I control.

My site is static and already behind Cloudflare, so a Worker adds a server-side step without adding a server to maintain.

Browser static site Cloudflare Worker signs ES256 JWT Apple Music API api.music.apple.com Worker secrets .p8 private key GET JSON Bearer JWT playlist
The key never leaves the dashed box. The browser only ever sees playlist JSON.

Signing the token

Workers don't run Node, so jsonwebtoken isn't available — the ES256 signature is built against the Web Crypto API instead.

const cryptoKey = await crypto.subtle.importKey(
  'pkcs8', keyBuffer,
  { name: 'ECDSA', namedCurve: 'P-256' },
  false,          // non-extractable
  ['sign']
);

const payload = { iss: teamId, iat: now, exp: now + 3600 };

Two details matter. The key is imported non-extractable, so even code inside the Worker can't read the raw bytes back out. And JWS expects the raw r‖s signature, which is what crypto.subtle.sign returns for ECDSA — the DER-wrapped form some libraries produce is silently rejected by Apple.

The part that took longest

Not the cryptography — the key formatting. importKey needs raw PKCS#8 bytes, but a .p8 arrives as PEM text, and passing through an environment variable mangles it in several different ways: newlines as literal \n pairs, the key collapsed to one line, Windows line endings, or the markers stripped entirely. Every variant fails in the same place with the same unhelpful error.

So the Worker normalises before it parses — unescape, re-wrap the base64 body at 64 characters, restore the markers, then decode. Unglamorous, and it's most of what makes this work across deploys instead of only on the machine where it was first set up.

Reliability

Tradeoffs

A Worker over a backend: no idle cost and nothing to patch, at the price of being coupled to the Workers runtime — which is exactly why the signing is hand-rolled.

The token is valid for an hour but a new one is minted per uncached request. Caching it would save a cheap signature and add cross-invocation state; with an hour of response caching in front, that hasn't been worth it.

← Back to projects