# Ekx Cloudflare Stream

> Signed video delivery with Cloudflare Stream for a paid course platform — RS256 JWT signing with jose, the PKCS#1 key gotcha, IP-pinned access rules, direct creator uploads, and the customer-code embed URL. Use when serving paid or gated video, minting a playback token, uploading video from an admin UI, or debugging a 401 on a Stream iframe.

- Skill: `ekinoxis-evm/ekx-cloudflare-stream` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ekinoxis-evm/ekx-cloudflare-stream`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ekinoxis-evm/ekx-cloudflare-stream/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Ekinoxis-evm (https://skillmd.com/u/ekinoxis-evm)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ekinoxis-evm/ekx-cloudflare-stream

---


# Cloudflare Stream

Video delivery for a course platform. Courses are **paid**, so every video sits behind a
signed URL; nothing is public.

## Setup

```bash
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

```ts
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

```ts
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.

```ts
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 `PUT`s 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:**

```bash
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/`](../../vendor/cloudflare/SOURCE.md).

**17 hosted MCP servers.** The two worth wiring by default:

```json
"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`](../../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 **`kid` missing from the payload**, then clock skew,
  then an expired token — check in that order.
- `maxDurationSeconds` is 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`](../ekx-pinata-ipfs/SKILL.md) — the other media pipeline, for NFT assets

