Skip to main content
Technology & EngineeringVideo Services358 lines

Cloudflare Stream

"Cloudflare Stream: video hosting, adaptive streaming, direct upload, watermarks, signed URLs, Workers integration"

Quick Summary27 lines
Cloudflare Stream is a video-on-demand and live streaming platform integrated into the Cloudflare ecosystem. It eliminates transcoding complexity — you upload a video, and Stream produces adaptive bitrate HLS/DASH outputs delivered over Cloudflare's edge network. Stream is opinionated: no codec knobs, no bitrate ladders, no storage buckets to configure. You get a video UID and an embed code. This simplicity is the point. Use Stream when you need reliable video delivery without building encoding pipelines. Pair it with Workers for custom access logic, signed URLs for gated content, and the Stream API for programmatic control. Direct creator uploads let end users upload without touching your origin.

## Key Points

- Use direct creator uploads with tus protocol for reliable, resumable client-side uploads.
- Enable `requireSignedURLs` on any video that needs access gating; public URLs cannot be revoked.
- Store the video `uid` in your database, not full URLs, since URL formats may change.
- Set `allowedOrigins` to restrict which domains can embed your videos.
- Use Workers KV or D1 to store access control rules close to the edge for low-latency auth checks.
- Create watermark profiles once and reuse them across uploads rather than recreating per video.
- Use the `meta` field to store your own identifiers and metadata on each video for easy cross-referencing.
- Handle the `error` state in webhooks; not all uploads succeed, and users need feedback.
- Do not proxy video uploads through your origin server; use direct upload URLs from Stream.
- Do not try to configure encoding settings, codecs, or bitrate ladders; Stream handles this automatically.
- Do not poll the API for video readiness; configure webhooks and react to status changes.
- Do not store signed tokens long-term; generate them on demand with short expiration times.

## Quick Example

```
CLOUDFLARE_API_TOKEN=your-api-token-with-stream-permissions
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_STREAM_SIGNING_KEY=your-signing-key-id
CLOUDFLARE_STREAM_SIGNING_PEM=your-pem-private-key
```
skilldb get video-services-skills/Cloudflare StreamFull skill: 358 lines
Paste into your CLAUDE.md or agent config

Cloudflare Stream

Core Philosophy

Cloudflare Stream is a video-on-demand and live streaming platform integrated into the Cloudflare ecosystem. It eliminates transcoding complexity — you upload a video, and Stream produces adaptive bitrate HLS/DASH outputs delivered over Cloudflare's edge network. Stream is opinionated: no codec knobs, no bitrate ladders, no storage buckets to configure. You get a video UID and an embed code. This simplicity is the point. Use Stream when you need reliable video delivery without building encoding pipelines. Pair it with Workers for custom access logic, signed URLs for gated content, and the Stream API for programmatic control. Direct creator uploads let end users upload without touching your origin.

Setup

Stream is accessed via the Cloudflare API. Install the SDK and configure credentials:

import Cloudflare from "cloudflare";

const cf = new Cloudflare({
  apiToken: process.env.CLOUDFLARE_API_TOKEN!,
});

const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;

Environment variables required:

CLOUDFLARE_API_TOKEN=your-api-token-with-stream-permissions
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_STREAM_SIGNING_KEY=your-signing-key-id
CLOUDFLARE_STREAM_SIGNING_PEM=your-pem-private-key

Ensure your API token has Stream:Edit and Stream:Read permissions.

Key Techniques

Uploading Video via URL

async function uploadFromUrl(videoUrl: string, name: string) {
  const response = await cf.stream.copy.create({
    account_id: ACCOUNT_ID,
    url: videoUrl,
    meta: {
      name,
    },
    allowedOrigins: ["example.com", "*.example.com"],
  });

  return {
    uid: response.uid,
    status: response.status,
    playback: {
      hls: `https://customer-${ACCOUNT_ID}.cloudflarestream.com/${response.uid}/manifest/video.m3u8`,
      dash: `https://customer-${ACCOUNT_ID}.cloudflarestream.com/${response.uid}/manifest/video.mpd`,
      iframe: `https://customer-${ACCOUNT_ID}.cloudflarestream.com/${response.uid}/iframe`,
    },
  };
}

Direct Creator Uploads (Client-Side)

Generate a one-time upload URL on the server, then upload from the browser:

// Server: create a direct upload URL
async function createDirectUploadUrl(maxDurationSeconds = 3600) {
  const response = await cf.stream.directUpload.create({
    account_id: ACCOUNT_ID,
    maxDurationSeconds,
    allowedOrigins: ["https://example.com"],
    meta: {
      uploadedBy: "user-123",
    },
    requireSignedURLs: false,
  });

  return { uploadUrl: response.uploadURL, uid: response.uid };
}

// Client: upload using tus-js-client for resumable uploads
import * as tus from "tus-js-client";

function uploadVideo(file: File, uploadUrl: string, onProgress: (pct: number) => void) {
  return new Promise<void>((resolve, reject) => {
    const upload = new tus.Upload(file, {
      endpoint: uploadUrl,
      chunkSize: 50 * 1024 * 1024, // 50 MB chunks
      retryDelays: [0, 3000, 5000, 10000],
      metadata: {
        name: file.name,
        filetype: file.type,
      },
      onError: reject,
      onProgress: (bytesUploaded, bytesTotal) => {
        onProgress(Math.round((bytesUploaded / bytesTotal) * 100));
      },
      onSuccess: () => resolve(),
    });

    upload.start();
  });
}

Signed URLs for Access Control

import { webcrypto } from "crypto";

async function createSignedToken(
  videoUid: string,
  signingKeyId: string,
  pemKey: string,
  expiresInSeconds = 3600
) {
  const encoder = new TextEncoder();
  const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;

  const header = { alg: "RS256", kid: signingKeyId };
  const payload = {
    sub: videoUid,
    kid: signingKeyId,
    exp: expiresAt,
    accessRules: [
      { type: "any", action: "allow" },
    ],
  };

  const headerB64 = btoa(JSON.stringify(header));
  const payloadB64 = btoa(JSON.stringify(payload));
  const signingInput = `${headerB64}.${payloadB64}`;

  const key = await webcrypto.subtle.importKey(
    "pkcs8",
    pemToBuffer(pemKey),
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
    false,
    ["sign"]
  );

  const signature = await webcrypto.subtle.sign(
    "RSASSA-PKCS1-v1_5",
    key,
    encoder.encode(signingInput)
  );

  const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)));
  return `${signingInput}.${signatureB64}`;
}

function pemToBuffer(pem: string): ArrayBuffer {
  const stripped = pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, "");
  const binary = atob(stripped);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes.buffer;
}

Watermarks

async function createWatermarkProfile(imageUrl: string) {
  const watermark = await cf.stream.watermarks.create({
    account_id: ACCOUNT_ID,
    url: imageUrl,
    name: "brand-logo",
    position: "upperRight",
    scale: 0.1,
    opacity: 0.8,
  });

  return watermark.uid;
}

async function uploadWithWatermark(videoUrl: string, watermarkUid: string) {
  const response = await cf.stream.copy.create({
    account_id: ACCOUNT_ID,
    url: videoUrl,
    watermark: { uid: watermarkUid },
  });

  return response.uid;
}

Workers Integration

Use a Cloudflare Worker to gate access or transform stream responses:

// Cloudflare Worker script
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const videoId = url.pathname.split("/")[2];

    if (!videoId) {
      return new Response("Video ID required", { status: 400 });
    }

    // Check auth
    const authHeader = request.headers.get("Authorization");
    const user = await validateToken(authHeader, env);
    if (!user) {
      return new Response("Unauthorized", { status: 401 });
    }

    // Check if user has access to this video
    const hasAccess = await env.VIDEO_ACCESS.get(`${user.id}:${videoId}`);
    if (!hasAccess) {
      return new Response("Forbidden", { status: 403 });
    }

    // Generate signed URL and redirect
    const token = await createSignedStreamToken(videoId, env);
    const streamUrl = `https://customer-${env.ACCOUNT_ID}.cloudflarestream.com/${token}/manifest/video.m3u8`;

    return Response.redirect(streamUrl, 302);
  },
};

interface Env {
  ACCOUNT_ID: string;
  STREAM_SIGNING_KEY_ID: string;
  STREAM_SIGNING_PEM: string;
  VIDEO_ACCESS: KVNamespace;
}

Video Management

async function manageVideos() {
  // List videos
  const videos = await cf.stream.list({
    account_id: ACCOUNT_ID,
    status: "ready",
  });

  // Get video details
  const video = await cf.stream.get(VIDEO_UID, {
    account_id: ACCOUNT_ID,
  });

  // Update video metadata
  await cf.stream.update(VIDEO_UID, {
    account_id: ACCOUNT_ID,
    meta: { title: "Updated Title", category: "tutorial" },
  });

  // Clip a video
  const clip = await cf.stream.clip.create({
    account_id: ACCOUNT_ID,
    clippedFromVideoUID: VIDEO_UID,
    startTimeSeconds: 30,
    endTimeSeconds: 90,
  });

  // Delete a video
  await cf.stream.delete(VIDEO_UID, {
    account_id: ACCOUNT_ID,
  });
}

Embedding the Stream Player

interface StreamPlayerProps {
  videoUid: string;
  signedToken?: string;
}

function StreamPlayer({ videoUid, signedToken }: StreamPlayerProps) {
  const src = signedToken || videoUid;

  return (
    <div style={{ position: "relative", paddingTop: "56.25%" }}>
      <iframe
        src={`https://customer-${process.env.NEXT_PUBLIC_CF_ACCOUNT_ID}.cloudflarestream.com/${src}/iframe`}
        style={{
          border: "none",
          position: "absolute",
          top: 0,
          left: 0,
          height: "100%",
          width: "100%",
        }}
        allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
        allowFullScreen
      />
    </div>
  );
}

Webhook Processing

import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const signature = req.headers["webhook-signature"] as string;

  // Verify signature using your webhook signing secret
  if (!verifyWebhookSignature(req.body, signature, process.env.CF_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const event = req.body;

  if (event.status?.state === "ready") {
    await db.video.update({
      where: { streamUid: event.uid },
      data: {
        status: "ready",
        duration: event.duration,
        thumbnailUrl: event.thumbnail,
      },
    });
  } else if (event.status?.state === "error") {
    await db.video.update({
      where: { streamUid: event.uid },
      data: { status: "error", errorMessage: event.status.errorReasonText },
    });
  }

  res.status(200).json({ ok: true });
}

Best Practices

  • Use direct creator uploads with tus protocol for reliable, resumable client-side uploads.
  • Enable requireSignedURLs on any video that needs access gating; public URLs cannot be revoked.
  • Store the video uid in your database, not full URLs, since URL formats may change.
  • Set allowedOrigins to restrict which domains can embed your videos.
  • Use Workers KV or D1 to store access control rules close to the edge for low-latency auth checks.
  • Create watermark profiles once and reuse them across uploads rather than recreating per video.
  • Use the meta field to store your own identifiers and metadata on each video for easy cross-referencing.
  • Handle the error state in webhooks; not all uploads succeed, and users need feedback.

Anti-Patterns

  • Do not proxy video uploads through your origin server; use direct upload URLs from Stream.
  • Do not try to configure encoding settings, codecs, or bitrate ladders; Stream handles this automatically.
  • Do not poll the API for video readiness; configure webhooks and react to status changes.
  • Do not store signed tokens long-term; generate them on demand with short expiration times.
  • Do not embed Stream videos without setting allowedOrigins; anyone can embed your content otherwise.
  • Do not assume uploads are instant; large files take time to process even after upload completes.
  • Do not use Stream for audio-only content; it is optimized for video and charges per minute of video stored.
  • Do not hardcode the customer subdomain; use environment variables so it works across accounts.

Install this skill directly: skilldb add video-services-skills

Get CLI access →