Skip to main content
The key server can run on Cloudflare Workers instead of Docker. Workers deploy to 300+ edge locations, giving viewers sub-50ms key fetch latency worldwide — without managing containers.

When to use Workers

Docker key serverCloudflare Workers
Centralized deploymentGlobal edge deployment
SQLite or Postgres for leasesKV or Durable Objects for leases
Runs anywhere Docker runsRuns on Cloudflare’s network
Bundled presign endpointCustom presign route (see below)
Best for: most deploymentsBest for: global latency-sensitive apps

Quick start

Install the keys package:
pnpm add @blindcast/keys
Create your Worker:
// src/index.ts
import { createWorkerKeyServer } from "@blindcast/keys"
import { hexToBytes } from "@blindcast/crypto"

interface Env {
  MASTER_KEY: string
  SALT: string
  CORS_ORIGINS: string
}

// Key server is created once per isolate (env is stable within a Worker isolate)
let keyServer: ReturnType<typeof createWorkerKeyServer> | undefined

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (!keyServer) {
      keyServer = createWorkerKeyServer({
        masterKey: hexToBytes(env.MASTER_KEY),
        salt: hexToBytes(env.SALT),
        corsOrigins: env.CORS_ORIGINS,
      })
    }
    return keyServer.fetch(request, env as unknown as Record<string, unknown>)
  },
}
Deploy with Wrangler:
npx wrangler deploy

Full example with authentication

import { createWorkerKeyServer } from "@blindcast/keys"
import { hexToBytes } from "@blindcast/crypto"

interface Env {
  MASTER_KEY: string
  SALT: string
  CORS_ORIGINS: string
  JWT_SECRET: string
}

let keyServer: ReturnType<typeof createWorkerKeyServer> | undefined

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (!keyServer) {
      keyServer = createWorkerKeyServer({
        masterKey: hexToBytes(env.MASTER_KEY),
        salt: hexToBytes(env.SALT),
        corsOrigins: env.CORS_ORIGINS,

        authenticate: async (request, env) => {
          const token = request.headers.get("Authorization")?.split(" ")[1]
          if (!token) {
            return { ok: false, status: 401, reason: "Missing token" }
          }

          // Verify JWT (use your preferred JWT library)
          const valid = await verifyJwt(token, env.JWT_SECRET as string)
          if (!valid) {
            return { ok: false, status: 401, reason: "Invalid token" }
          }

          return { ok: true }
        },
      })
    }
    return keyServer.fetch(request, env as unknown as Record<string, unknown>)
  },
}

Wrangler configuration

# wrangler.toml
name = "blindcast-keyserver"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[vars]
CORS_ORIGINS = "https://your-app.com"

# Store secrets with: npx wrangler secret put MASTER_KEY
# Store secrets with: npx wrangler secret put SALT
Never put MASTER_KEY or SALT in wrangler.toml. Use wrangler secret put to store them as encrypted secrets.

Endpoints

The Worker key server exposes:
MethodPathDescription
GET/keys/:contentIdContent key (16 raw bytes)
GET/keys/:contentId/:epochEpoch key (for key rotation)
GET/healthHealth check
The Worker key server does not include lease or presign endpoints. For leases, implement a KV-backed LeaseStore (see below). For presigned uploads, add a custom presign route (see Presign Endpoint below).

Leases on Workers

The in-memory LeaseStore does not work on Workers — each isolate has independent memory and does not share state across requests. Use Cloudflare KV or Durable Objects instead.

KV-backed lease store

import { createWorkerKeyServer } from "@blindcast/keys"
import type { LeaseStore } from "@blindcast/keys"

function createKVLeaseStore(kv: KVNamespace): LeaseStore {
  return {
    async create(leaseId, viewerId, contentId, expiresAt) {
      await kv.put(`lease:${leaseId}`, JSON.stringify({
        viewerId, contentId, expiresAt: expiresAt.toISOString(), revoked: false,
      }), { expirationTtl: Math.ceil((expiresAt.getTime() - Date.now()) / 1000) })
    },

    async validate(leaseId, contentId) {
      const raw = await kv.get(`lease:${leaseId}`)
      if (!raw) return { ok: false, code: "LEASE_NOT_FOUND" }
      const lease = JSON.parse(raw)
      if (lease.revoked) return { ok: false, code: "LEASE_REVOKED" }
      if (new Date(lease.expiresAt) < new Date()) return { ok: false, code: "LEASE_EXPIRED" }
      if (lease.contentId !== contentId) return { ok: false, code: "LEASE_CONTENT_MISMATCH" }
      return { ok: true }
    },

    async revoke(leaseId) {
      const raw = await kv.get(`lease:${leaseId}`)
      if (!raw) return
      const lease = JSON.parse(raw)
      lease.revoked = true
      await kv.put(`lease:${leaseId}`, JSON.stringify(lease))
    },

    // Additional methods: renew, revokeByViewer, cleanup
  }
}
Add the KV binding to wrangler.toml:
[[kv_namespaces]]
binding = "LEASES"
id = "your-kv-namespace-id"

Presign endpoint

The Docker key server bundles a presign endpoint for browser uploads. On Workers, you add a custom presign route alongside the key server. The Uploader SDK requires a presignUrl to upload encrypted segments from the browser.

Use aws4fetch, not the AWS SDK

@aws-sdk/s3-request-presigner (~500 KB) does not work in Cloudflare Workers — it depends on Node.js APIs. Use aws4fetch (~2.5 KB) instead.
pnpm add aws4fetch

Full Worker with presign

This example composes createWorkerKeyServer() with a custom presign route for R2:
// src/index.ts
import { createWorkerKeyServer } from "@blindcast/keys"
import { hexToBytes } from "@blindcast/crypto"
import { AwsClient } from "aws4fetch"

interface Env {
  MASTER_KEY: string          // hex, wrangler secret
  SALT: string                // hex, wrangler secret
  R2_ACCESS_KEY_ID: string    // wrangler secret
  R2_SECRET_ACCESS_KEY: string // wrangler secret
  R2_ACCOUNT_ID: string       // wrangler var
  R2_BUCKET_NAME: string      // wrangler var
  CORS_ORIGINS: string        // wrangler var (comma-separated)
}

// Key server is created once per isolate (env is stable within a Worker isolate)
let keyServer: ReturnType<typeof createWorkerKeyServer> | undefined

function getKeyServer(env: Env) {
  if (!keyServer) {
    keyServer = createWorkerKeyServer({
      masterKey: hexToBytes(env.MASTER_KEY),
      salt: hexToBytes(env.SALT),
      corsOrigins: env.CORS_ORIGINS.split(",").map((s) => s.trim()),
    })
  }
  return keyServer
}

function getAllowedOrigin(request: Request, env: Env): string {
  const requestOrigin = request.headers.get("Origin") ?? ""
  const allowed = env.CORS_ORIGINS.split(",").map((s) => s.trim())
  if (allowed.includes(requestOrigin)) return requestOrigin
  return allowed[0] ?? "*"
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)
    const origin = getAllowedOrigin(request, env)

    // CORS preflight for presign route
    if (request.method === "OPTIONS" && url.pathname === "/presign") {
      return new Response(null, {
        status: 204,
        headers: {
          "Access-Control-Allow-Origin": origin,
          "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
          "Access-Control-Allow-Headers": "Content-Type, Authorization",
          "Access-Control-Max-Age": "86400",
        },
      })
    }

    // Custom presign route
    // ⚠️ No authentication — add an Authorization check for production.
    if (url.pathname === "/presign" && request.method === "POST") {
      return handlePresign(request, env, origin)
    }

    // Everything else → key server (/keys/*, /health, CORS)
    return getKeyServer(env).fetch(
      request,
      env as unknown as Record<string, unknown>,
    )
  },
}

async function handlePresign(
  request: Request,
  env: Env,
  origin: string,
): Promise<Response> {
  // contentType is sent by the uploader SDK but intentionally not included
  // in the presign signature (browsers add unsigned headers that break it).
  let body: { keys: { key: string; contentType: string }[] }
  try {
    body = await request.json()
  } catch {
    return json(400, { error: "Invalid JSON body" }, origin)
  }

  if (!Array.isArray(body.keys) || body.keys.length === 0) {
    return json(400, { error: "Missing or empty keys array" }, origin)
  }

  // Validate key patterns (prevent path traversal)
  for (const { key } of body.keys) {
    if (!/^[a-zA-Z0-9/_.-]+$/.test(key) || key.includes("..")) {
      return json(400, { error: "Invalid key pattern" }, origin)
    }
  }

  const client = new AwsClient({
    accessKeyId: env.R2_ACCESS_KEY_ID,
    secretAccessKey: env.R2_SECRET_ACCESS_KEY,
    service: "s3",
    region: "auto",
  })

  const r2Endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`

  const urls = await Promise.all(
    body.keys.map(async ({ key }) => {
      const signed = await client.sign(
        new Request(
          `${r2Endpoint}/${env.R2_BUCKET_NAME}/${key}?X-Amz-Expires=300`,
          { method: "PUT" },
        ),
        { aws: { signQuery: true } },
      )
      return { key, url: signed.url }
    }),
  )

  return json(200, { urls }, origin)
}

function json(status: number, body: unknown, origin: string): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": origin,
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    },
  })
}
The presign endpoint above has no authentication — any browser from a CORS-allowed origin can generate presigned PUT URLs. This is intentionally simplified for the example. Production deployments should add an Authorization check before generating URLs.
Do not include Content-Type in the signing request headers when generating presigned URLs. Browsers may add unsigned headers to the actual PUT request, breaking the signature.

Wrangler configuration (with presign)

# wrangler.toml
name = "blindcast-keys"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[vars]
R2_ACCOUNT_ID = ""
R2_BUCKET_NAME = "your-bucket-name"
CORS_ORIGINS = "https://your-app.com"

# Secrets — store with `wrangler secret put`, NOT in this file:
# MASTER_KEY
# SALT
# R2_ACCESS_KEY_ID
# R2_SECRET_ACCESS_KEY

Deployment steps

  1. Install dependencies:
pnpm add @blindcast/keys aws4fetch
pnpm add -D wrangler @cloudflare/workers-types
  1. Store secrets (one at a time — wrangler prompts for the value):
npx wrangler secret put MASTER_KEY
npx wrangler secret put SALT
npx wrangler secret put R2_ACCESS_KEY_ID
npx wrangler secret put R2_SECRET_ACCESS_KEY
  1. Deploy:
npx wrangler deploy
  1. Verify:
curl https://blindcast-keys.<subdomain>.workers.dev/health
# → {"status":"ok"}

How env works on Workers

Cloudflare injects env as the second argument to your Worker’s fetch handler. Values come from two sources:
  • [vars] in wrangler.toml — for non-sensitive configuration (bucket name, account ID, CORS origins)
  • wrangler secret put — for sensitive values (master key, salt, R2 credentials). Stored encrypted, not in source control.
Both are accessed the same way: env.R2_BUCKET_NAME, env.MASTER_KEY, etc.

Differences from Docker

FeatureDockerWorkers
Key derivationHKDF-SHA-256HKDF-SHA-256 (identical)
AuthenticationJWT via env varsCustom authenticate callback
LeasesSQLite / PostgresKV / Durable Objects
Presign endpointBuilt-inCustom route (see above)
DatabaseAuto-migratedNot applicable
Health checkGET /healthGET /health
Deploymentdocker runwrangler deploy

Player configuration

The player connects to a Worker key server the same way as Docker — just point to the Worker URL:
import { createPlayer } from "@blindcast/player"

const player = createPlayer(videoElement, {
  keyServerUrl: "https://blindcast-keyserver.your-account.workers.dev/keys",
  // Everything else is identical
})

Storage: R2

Cloudflare R2 is S3-compatible object storage with no egress fees — ideal for serving encrypted video segments. When paired with Workers, it gives you a fully Cloudflare-native deployment.

Create an R2 bucket

npx wrangler r2 bucket create blindcast-videos

Enable public access

Encrypted segments need to be publicly readable so the player can fetch them:
  • r2.dev subdomain (simplest): Enable in R2 → your bucket → Settings → Public access. Segments are accessible at https://<bucket>.<accountId>.r2.dev/<key>.
  • Custom domain (production): Add a CNAME record pointing to your R2 bucket under R2 → your bucket → Settings → Custom domains.

R2 API credentials

The presign endpoint above needs S3-compatible credentials:
  1. Go to R2 → Manage R2 API Tokens → Create API Token
  2. Select Object Read & Write permission, scoped to your bucket
  3. Save the Access Key ID and Secret Access Key
  4. Store them as Worker secrets: wrangler secret put R2_ACCESS_KEY_ID and wrangler secret put R2_SECRET_ACCESS_KEY

CORS for browser uploads

When the Uploader SDK uploads encrypted segments from the browser via presigned URLs, R2 must allow the cross-origin PUT request:
{
  "rules": [
    {
      "allowed": {
        "origins": ["https://your-app.com"],
        "methods": ["PUT", "GET", "HEAD"],
        "headers": ["Content-Type", "Content-Length"]
      },
      "exposed_headers": ["ETag"],
      "max_age_seconds": 3600
    }
  ]
}
npx wrangler r2 bucket cors set blindcast-videos --file cors.json
For local development, add http://localhost:5173 (or your dev server port) to the origins array. Remove it before deploying to production.
For CLI uploads to R2 and caching guidance, see blindcast upload and CDN Configuration.

When to choose Docker instead

Use the Docker key server when:
  • You prefer the bundled presign endpoint (Workers require a custom route — see above)
  • You want SQLite or Postgres lease storage without custom code
  • You’re already running containers and don’t need edge latency
  • You need all endpoints in a single deployment