> ## Documentation Index
> Fetch the complete documentation index at: https://docs.blindcast.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Cloudflare Workers

> Deploy the key server to Cloudflare Workers for global edge latency. Uses the standard Fetch API — no Docker, no Express.

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 server             | Cloudflare Workers                                    |
| ----------------------------- | ----------------------------------------------------- |
| Centralized deployment        | Global edge deployment                                |
| SQLite or Postgres for leases | KV or Durable Objects for leases                      |
| Runs anywhere Docker runs     | Runs on Cloudflare's network                          |
| Bundled presign endpoint      | Custom presign route ([see below](#presign-endpoint)) |
| Best for: most deployments    | Best for: global latency-sensitive apps               |

## Quick start

Install the keys package:

```bash theme={null}
pnpm add @blindcast/keys
```

Create your Worker:

```typescript theme={null}
// 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:

```bash theme={null}
npx wrangler deploy
```

## Full example with authentication

```typescript theme={null}
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

```toml theme={null}
# 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
```

<Warning>
  Never put `MASTER_KEY` or `SALT` in `wrangler.toml`. Use `wrangler secret put` to store them as encrypted secrets.
</Warning>

## Endpoints

The Worker key server exposes:

| Method | Path                      | Description                  |
| ------ | ------------------------- | ---------------------------- |
| `GET`  | `/keys/:contentId`        | Content key (16 raw bytes)   |
| `GET`  | `/keys/:contentId/:epoch` | Epoch key (for key rotation) |
| `GET`  | `/health`                 | Health check                 |

<Note>
  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](#presign-endpoint) below).
</Note>

## 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

```typescript theme={null}
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`:

```toml theme={null}
[[kv_namespaces]]
binding = "LEASES"
id = "your-kv-namespace-id"
```

## Presign endpoint

The [Docker key server](/key-server/presign) bundles a presign endpoint for browser uploads. On Workers, you add a custom presign route alongside the key server. The [Uploader SDK](/uploader/overview) requires a `presignUrl` to upload encrypted segments from the browser.

### Use `aws4fetch`, not the AWS SDK

<Warning>
  `@aws-sdk/s3-request-presigner` (\~500 KB) does **not** work in Cloudflare Workers — it depends on Node.js APIs. Use [`aws4fetch`](https://github.com/mhart/aws4fetch) (\~2.5 KB) instead.
</Warning>

```bash theme={null}
pnpm add aws4fetch
```

### Full Worker with presign

This example composes `createWorkerKeyServer()` with a custom presign route for R2:

```typescript theme={null}
// 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",
    },
  })
}
```

<Warning>
  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.
</Warning>

<Warning>
  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.
</Warning>

### Wrangler configuration (with presign)

```toml theme={null}
# 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:**

```bash theme={null}
pnpm add @blindcast/keys aws4fetch
pnpm add -D wrangler @cloudflare/workers-types
```

2. **Store secrets** (one at a time — wrangler prompts for the value):

```bash theme={null}
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
```

3. **Deploy:**

```bash theme={null}
npx wrangler deploy
```

4. **Verify:**

```bash theme={null}
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

| Feature          | Docker            | Workers                                       |
| ---------------- | ----------------- | --------------------------------------------- |
| Key derivation   | HKDF-SHA-256      | HKDF-SHA-256 (identical)                      |
| Authentication   | JWT via env vars  | Custom `authenticate` callback                |
| Leases           | SQLite / Postgres | KV / Durable Objects                          |
| Presign endpoint | Built-in          | Custom route ([see above](#presign-endpoint)) |
| Database         | Auto-migrated     | Not applicable                                |
| Health check     | `GET /health`     | `GET /health`                                 |
| Deployment       | `docker run`      | `wrangler deploy`                             |

## Player configuration

The player connects to a Worker key server the same way as Docker — just point to the Worker URL:

```typescript theme={null}
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

```bash theme={null}
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](/uploader/overview) uploads encrypted segments from the browser via presigned URLs, R2 must allow the cross-origin PUT request:

```json theme={null}
{
  "rules": [
    {
      "allowed": {
        "origins": ["https://your-app.com"],
        "methods": ["PUT", "GET", "HEAD"],
        "headers": ["Content-Type", "Content-Length"]
      },
      "exposed_headers": ["ETag"],
      "max_age_seconds": 3600
    }
  ]
}
```

```bash theme={null}
npx wrangler r2 bucket cors set blindcast-videos --file cors.json
```

<Warning>
  For local development, add `http://localhost:5173` (or your dev server port) to the `origins` array. Remove it before deploying to production.
</Warning>

For CLI uploads to R2 and caching guidance, see [`blindcast upload`](/cli/upload) and [CDN Configuration](/production/cdn).

## When to choose Docker instead

Use the Docker key server when:

* You prefer the bundled presign endpoint (Workers require a custom route — [see above](#presign-endpoint))
* 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
