Cloudflare Stream
Video delivery for a course platform. Courses are paid, so every video sits behind a signed URL; nothing is public.
Setup
npm i jose
CF_STREAM_ACCOUNT_ID= # Cloudflare account id
CF_STREAM_API_TOKEN= # API token with Stream:Edit
CF_STREAM_KEY_ID= # signing key id
CF_STREAM_PEM= # signing private key, BASE64-encoded
CF_STREAM_CUSTOMER_CODE= # the customer-<code> subdomain
All server-only. CF_STREAM_PEM is base64 because a PEM's newlines do not survive most
env-var UIs intact.
Signing a playback token
import { SignJWT } from "jose";
import { createPrivateKey } from "node:crypto";
export async function signStreamToken(videoUid: string, opts: { clientIp?: string } = {}) {
const pem = Buffer.from(process.env.CF_STREAM_PEM!, "base64").toString("utf-8");
const privateKey = createPrivateKey(pem);
const payload: Record<string, unknown> = { kid: process.env.CF_STREAM_KEY_ID };
if (opts.clientIp && opts.clientIp !== "unknown") {
payload.accessRules = [
{ type: "ip.src", ip: [`${opts.clientIp}/32`], action: "allow" },
{ type: "any", action: "block" },
];
}
return new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", kid: process.env.CF_STREAM_KEY_ID })
.setSubject(videoUid)
.setExpirationTime("1h")
.setNotBefore(Math.floor(Date.now() / 1000) - 5)
.sign(privateKey);
}
Three non-obvious requirements
1 · createPrivateKey, not jose.importPKCS8. Cloudflare issues the signing key as
PKCS#1 (BEGIN RSA PRIVATE KEY). importPKCS8 rejects it outright. Node's
createPrivateKey auto-detects PKCS#1 vs PKCS#8 and handles both. This costs an afternoon
if you don't know it.
2 · kid goes in the payload and the header. Cloudflare resolves the signing key from
the payload kid. Header-only — which is what every JWT tutorial shows — returns a
401 with nothing useful in the body.
3 · setNotBefore(now - 5). A five-second backdate absorbs clock skew between your
server and Cloudflare's edge. Without it a freshly minted token is intermittently rejected.
IP pinning
Without accessRules, a signed token is a one-hour shareable bearer — anyone who pulls
the URL out of the iframe can stream the paid content. Pinning to ip.src with a
block-everything-else rule means the token only plays from the IP that minted it.
Trade-off, accepted here: a user on mobile data whose IP rotates mid-video gets cut off. Acceptable because the token expires in an hour anyway and a refresh re-mints from the new IP. If your audience is mostly mobile, weigh this differently.
Pass clientIp explicitly and skip pinning when it is "unknown" — never pin to a value
you failed to resolve, or you lock everyone out.
Embed URL
export function streamEmbedUrl(token: string) {
return `https://customer-${CUSTOMER_CODE}.cloudflarestream.com/${token}/iframe`;
}
The token replaces the video id in the path. The uid never appears in a signed URL — that's the point.
Direct creator upload
Upload straight from the browser to Cloudflare; the file never touches your server.
const res = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/stream/direct_upload`,
{
method: "POST",
headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({
maxDurationSeconds: 7200,
requireSignedURLs: true, // ← set at upload time; retrofitting is painful
meta: { name: filename },
}),
},
);
const { uid, uploadURL } = (await res.json()).result;
Return uploadURL to the admin client, which PUTs the file to it. Persist uid.
requireSignedURLs: true must be set when the video is created. A video uploaded
without it is publicly playable by uid, and flipping the flag afterwards means touching
every existing asset.
Official skills and MCP
Cloudflare ships both — use them for anything beyond Stream:
npx skills add https://github.com/cloudflare/skills
13 skills covering Workers, Wrangler, Durable Objects, R2/D1/KV, the Agents SDK, Turnstile
and web performance. Vendored at ../../vendor/cloudflare/.
17 hosted MCP servers. The two worth wiring by default:
"cloudflare-docs": { "type": "http", "url": "https://docs.mcp.cloudflare.com/mcp" },
"cloudflare-api": { "type": "http", "url": "https://mcp.cloudflare.com/mcp" }
The API server covers 2,500+ endpoints; the full list is in
../../vendor/cloudflare/SOURCE.md.
Note: neither the official skills nor the MCP servers cover Stream signing, which is why this document exists.
Gotchas
- A 401 on the iframe is almost always
kidmissing from the payload, then clock skew, then an expired token — check in that order. maxDurationSecondsis a hard ceiling at upload. 7200 = 2 hours.- Don't log the signed token; it is a bearer credential for the hour.
See also
ekx-pinata-ipfs — the other media pipeline, for NFT assets