Cloudflare Workers + Access: Make Your API Private to Only You

A practical guide for Cloudflare Workers created with C3 (create-cloudflare).

TL;DR


1) What “private” means for Workers

Workers run on Cloudflare’s edge. They are not like a private VM inside your VPC.

So there are two different security goals:

A. “Only I can call my Worker API”

Use:

B. “My Worker should be able to call my private backend”

Use:

These are separate problems. Most people asking to make a Worker private mean A. Service Bindings are a special case of A for Cloudflare-to-Cloudflare calls.

A quick visual summary:

flowchart TB
    Goal[What do you want to make private?] --> Callers[Who can call my Worker]
    Goal --> Upstream[What my Worker can call]

    Callers --> Access[Cloudflare Access]
    Access --> Human[Human access\nEmail / SSO / OTP]
    Access --> Machine[External automation\nService token]
    Callers --> Binding[Internal caller\nService Binding]

    Upstream --> VPC[Workers VPC / VPC Service]
    VPC --> Tunnel[Cloudflare Tunnel]
    Tunnel --> Origin[Private origin / internal API]

2) Pick your deployment shape first

You normally expose a Worker through one or more of these:

These are separate entrypoints to the same deployed Worker:

flowchart LR
    WD[workers.dev] --> W[Your Worker]
    PU[Preview URL] --> W
    CD[Custom Domain] --> W
    RT[Route] --> W

What a Preview URL actually is

A Preview URL is an extra workers.dev hostname that points to a specific version of your Worker so you can test or review that version directly.

Cloudflare currently documents two types:

Typical shapes look like:

https://<version-prefix>-my-api.<subdomain>.workers.dev
https://staging-my-api.<subdomain>.workers.dev

Why this matters:

Current Cloudflare behavior:

For a personal API, the safest default is: do not leave Preview URLs enabled unless you actively use them.

Small personal API

Production or serious internal API


3) Option 1 — Workers + Access (email-only)

This is the simplest way to make your API accessible only to you.

3.1 Deploy your Worker

If you already created the project with C3, deploy normally.

npm run deploy

Or with Wrangler:

npx wrangler deploy

After deploy, note your Worker URL, for example:

https://my-api.<your-subdomain>.workers.dev

3.2 Enable Cloudflare Access on workers.dev

In the Cloudflare dashboard:

  1. Go to Workers & Pages
  2. Open your Worker
  3. Go to Settings → Domains & Routes
  4. For workers.dev, click Enable Cloudflare Access
  5. Click Manage Cloudflare Access

That creates or opens the Access application protecting that Worker URL.

If you have no identity provider

Cloudflare Access can use one-time PIN (OTP) by email. For a single-user setup, this is usually enough.

That means you can log in with your email and receive a code in your inbox.


3.3 Restrict access to only your email

Inside Cloudflare Access:

  1. Open the application for your Worker
  2. Add or edit a policy
  3. Set:
    • Action: Allow
    • Rule / Selector: Emails
    • Value: your-email@example.com
  4. Save the policy

Now the Worker is behind Access and only your email address can authenticate.

For a very locked-down personal API:


Cloudflare explicitly recommends validating the JWT that Access injects into the request.

The incoming request will contain:

You should verify:

Install jose

npm install jose

Worker example (src/index.ts)

import { createRemoteJWKSet, jwtVerify } from "jose";

export interface Env {
  TEAM_DOMAIN: string; // e.g. https://your-team.cloudflareaccess.com
  POLICY_AUD: string;  // your Access app AUD tag
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const token = request.headers.get("cf-access-jwt-assertion");

    if (!env.TEAM_DOMAIN || !env.POLICY_AUD) {
      return new Response("Missing Access configuration", { status: 500 });
    }

    if (!token) {
      return new Response("Missing CF Access JWT", { status: 403 });
    }

    try {
      const JWKS = createRemoteJWKSet(
        new URL(`${env.TEAM_DOMAIN}/cdn-cgi/access/certs`)
      );

      const { payload } = await jwtVerify(token, JWKS, {
        issuer: env.TEAM_DOMAIN,
        audience: env.POLICY_AUD,
      });

      // Optional: ensure the authenticated email matches exactly who you expect.
      const email = payload.email;
      if (email !== "your-email@example.com") {
        return new Response("Forbidden", { status: 403 });
      }

      return Response.json({
        ok: true,
        message: "Authenticated",
        email,
      });
    } catch (err) {
      return new Response(`Invalid Access token: ${String(err)}`, {
        status: 403,
      });
    }
  },
};

Add the Access values to your Worker

After you enable Access, get these values from the Access app:

Put them in your Worker config.

Example wrangler.jsonc:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-private-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-03-22",
  "workers_dev": true,
  "preview_urls": false,
  "vars": {
    "TEAM_DOMAIN": "https://your-team.cloudflareaccess.com",
    "POLICY_AUD": "paste-your-aud-tag-here"
  }
}

Then redeploy:

npx wrangler deploy

3.5 Test it

Browser test

Open the Worker URL. You should be redirected to Cloudflare Access and asked to authenticate. After successful login, the request reaches the Worker.

CLI test with cloudflared

This is useful when you want to call your own protected API from a terminal.

cloudflared access login https://my-api.<subdomain>.workers.dev
cloudflared access curl https://my-api.<subdomain>.workers.dev

Or get a token and use it with curl:

export TOKEN=$(cloudflared access token -app=https://my-api.<subdomain>.workers.dev)
curl -H "cf-access-token: $TOKEN" https://my-api.<subdomain>.workers.dev

This works because cloudflared is acting as a client for Access, not because the Worker is behind a tunnel.

The request path looks like this:

flowchart LR
    Client[Browser or cloudflared client] --> Access[Cloudflare Access]
    Access --> Host[Protected Worker hostname]
    Host --> Worker[Worker code]
    Worker --> JWT[Validate Cf-Access-Jwt-Assertion]

4) Option 2 — Workers + Access service token

Use this when the caller is not a human browser session, for example:

4.1 Create the service token

In Cloudflare Zero Trust:

  1. Go to Access controls → Service credentials → Service Tokens
  2. Click Create Service Token
  3. Give it a name, for example my-private-worker-client
  4. Choose a duration / expiration
  5. Copy:
    • Client ID
    • Client Secret

Save the secret immediately. Cloudflare only shows it once.


4.2 Add a Service Auth policy to the Worker’s Access app

Open the Access application protecting your Worker and add a second policy:

Typical policy layout

If you want both human and machine access, use two policies:

  1. Allow → your personal email
  2. Service Auth → your service token

This gives you:

If you want machine-only access, remove the human Allow policy.


4.3 Call the Worker with the service token

Initial request

curl \
  -H "CF-Access-Client-Id: <CLIENT_ID>" \
  -H "CF-Access-Client-Secret: <CLIENT_SECRET>" \
  https://my-api.example.com

If valid, Access will issue a CF_Authorization token scoped to that application.

Subsequent request using the Access token

curl \
  -H "cf-access-token: <CF_AUTHORIZATION_COOKIE_VALUE>" \
  https://my-api.example.com

Optional: single-header mode

For applications that can only send one custom header, Access can also accept a single header instead of the CF-Access-Client-Id + CF-Access-Client-Secret pair.


4.4 Best practice for scripts

Do not hard-code the service token in source code.

Use:

Example:

export CF_ACCESS_CLIENT_ID="..."
export CF_ACCESS_CLIENT_SECRET="..."

curl \
  -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
  https://my-api.example.com/private

The machine-to-machine flow looks like this:

sequenceDiagram
    participant S as Script / CI / backend
    participant A as Cloudflare Access
    participant W as Private Worker

    S->>A: Request with Client ID + Client Secret
    A-->>S: CF_Authorization token / allow
    S->>W: Request through protected hostname
    W-->>S: Protected response

4.5 Alternative for Worker-to-Worker calls: Service Bindings

Use Service Bindings when:

Do not use Service Bindings when the caller is:

For those callers, keep using Cloudflare Access service tokens.

Why Service Bindings are different

Cloudflare documents Service Bindings as an internal Worker-to-Worker communication mechanism. The caller declares a binding in wrangler, and that binding gives it permission to call the target Worker.

This means:

Cloudflare currently supports two call styles:

For most new designs, Cloudflare recommends RPC. If your target Worker already has a normal HTTP fetch() handler, HTTP-style Service Bindings are often the easiest migration path.

What RPC means here

RPC means Remote Procedure Call.

In this context, it means Worker A can call a method exposed by Worker B almost like calling a normal async function:

const result = await env.AUTH_SERVICE.checkSession("abc123");

That is different from HTTP-style bindings, where you call the other Worker’s fetch() handler with a Request:

const response = await env.AUTH_SERVICE.fetch(
  "https://auth-service.internal/check"
);

So the mental model is:

RPC is still remote and asynchronous, so you should await it. For RPC-style Service Bindings, the target Worker typically exposes methods by extending WorkerEntrypoint.

Visually:

flowchart LR
    Caller[Worker A or Pages Function] -->|Service Binding| Target[Worker B]
    Target --> API["RPC methods or fetch()"]
    Secrets[No shared secret in code] -.-> Target
    External[External clients] --> Access2[Cloudflare Access]
    Access2 -.-> Target

Example: bind Worker A to Worker B

Caller Worker (worker-a) wrangler.jsonc:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "worker-a",
  "main": "src/index.ts",
  "services": [
    {
      "binding": "AUTH_SERVICE",
      "service": "worker-b"
    }
  ]
}

Target Worker (worker-b) wrangler.jsonc:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "worker-b",
  "main": "src/index.ts"
}

HTTP-style call from Worker A:

export interface Env {
  AUTH_SERVICE: Fetcher;
}

export default {
  async fetch(_request: Request, env: Env): Promise<Response> {
    const authResponse = await env.AUTH_SERVICE.fetch(
      "https://auth-service.internal/check"
    );

    if (!authResponse.ok) {
      return new Response("Forbidden", { status: 403 });
    }

    return new Response("OK");
  },
};

Important limitation

Service Bindings are not a replacement for Cloudflare Access on a public Worker hostname.

If humans, scripts, CI, or tools outside Cloudflare need to call the Worker over workers.dev, a Route, or a Custom Domain, you should still protect that hostname with Cloudflare Access and use:


5) Can I access a “private Worker” using cloudflared tunnel?

Short answer

Yes, but only in a specific sense

You can use cloudflared as a client to access an Access-protected Worker:

cloudflared access login https://my-api.example.com
cloudflared access curl https://my-api.example.com

That is valid and useful.

No, not as the mechanism that makes the Worker private

A Cloudflare Worker itself is not privatized by putting it behind a Cloudflare Tunnel.

Tunnel is mainly for connecting your private origin/network to Cloudflare. The Worker still needs a public Cloudflare hostname (workers.dev, route, or custom domain) and Access is what enforces who can reach it.

So:


6) The other meaning of Tunnel: Worker → private origin (Workers VPC)

This is the case where your Worker should call something private like:

In that design:

flowchart LR
    User[You or approved client] --> Access[Cloudflare Access]
    Access --> Worker[Public Worker hostname]
    Worker --> VPC[Workers VPC Service]
    VPC --> Tunnel[Cloudflare Tunnel]
    Tunnel --> Origin[Private origin / internal API]

The Worker is still exposed through a Cloudflare URL, but callers are gated by Access. The Worker then reaches your private backend through Tunnel.

6.1 Create the Tunnel

In Cloudflare:

  1. Go to Workers VPC or Cloudflare Tunnel dashboard
  2. Create a tunnel
  3. Install cloudflared on a machine that can reach your private service
  4. Start/connect the tunnel using the token Cloudflare gives you

Example install flow (actual command differs by OS and the dashboard-generated token):

cloudflared service install <TUNNEL_TOKEN>

6.2 Create a VPC Service

In Workers VPC:

  1. Create a VPC Service
  2. Select the tunnel
  3. Point it at the internal hostname/IP and port of your private service
    • for example internal-api.company.local
    • or 10.0.1.50:8080

6.3 Bind the VPC Service to your Worker

Example wrangler.jsonc shape:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "private-api-gateway",
  "main": "src/index.ts",
  "compatibility_date": "2026-03-22",
  "services": [
    {
      "binding": "PRIVATE_API",
      "service": "my-private-api"
    }
  ]
}

Depending on your Workers VPC setup, the exact binding form shown in the dashboard may differ. Use the binding snippet Cloudflare shows for your VPC Service.

Example Worker code:

export interface Env {
  PRIVATE_API: Fetcher;
}

export default {
  async fetch(_request: Request, env: Env): Promise<Response> {
    const upstream = await env.PRIVATE_API.fetch(
      "http://internal-api.company.local/health"
    );

    return new Response(await upstream.text(), {
      status: upstream.status,
      headers: { "content-type": "text/plain" },
    });
  },
};

This setup is excellent when:


Use this checklist for a Worker that only you should access.

Minimum acceptable

Better

For automation

For Worker-to-Worker calls

For private backends


8) Example configurations

8.1 Personal Worker on workers.dev

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-private-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-03-22",
  "workers_dev": true,
  "preview_urls": false,
  "vars": {
    "TEAM_DOMAIN": "https://your-team.cloudflareaccess.com",
    "POLICY_AUD": "your-aud-tag"
  }
}

Use this when:


8.2 Custom-domain Worker with Access + service token

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-private-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-03-22",
  "workers_dev": false,
  "preview_urls": false,
  "vars": {
    "TEAM_DOMAIN": "https://your-team.cloudflareaccess.com",
    "POLICY_AUD": "your-aud-tag"
  }
}

Then:

Use this when:


8.3 Internal Worker-to-Worker call with Service Bindings

Caller Worker wrangler.jsonc:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "gateway-worker",
  "main": "src/index.ts",
  "workers_dev": false,
  "services": [
    {
      "binding": "PRIVATE_SERVICE",
      "service": "private-service"
    }
  ]
}

Use this when:

Remember:


9) Troubleshooting

I still can access the Worker publicly

Check all exposed entrypoints:

Browser works, but curl/script gets redirected to login

That usually means your request is missing:

A service token still shows the login page

The Access policy is probably wrong.

Make sure the application policy action is Service Auth. If the action is not Service Auth, Access will try to send the caller through human login.

JWT validation fails inside the Worker

Check:

I only want CLI access and no browser usage

Use:

You do not need cloudflared tunnel for that.

I want no keys or tokens between two Workers

If both sides are Cloudflare Workers or Pages Functions on your account, use a Service Binding.

That is the cleanest way to avoid manually managing secrets between services. If one side is outside Cloudflare, you still need Access with a service token or another authentication mechanism.


If your goal is:

“I want my C3 API project accessible only by me.”

I recommend this exact stack:

At a glance:

flowchart TB
    Start[Who needs to call the Worker?] --> Human[Just you in browser or CLI]
    Start --> External[External script / CI / backend]
    Start --> Internal[Another Worker / Pages Function]

    Human --> AccessEmail[Access\nAllow your email]
    External --> ServiceToken[Access\nService Auth + service token]
    Internal --> Binding[Service Binding]

    AccessEmail --> Upstream{Need private upstreams too?}
    ServiceToken --> Upstream
    Binding --> Upstream

    Upstream -->|Yes| Tunnel[VPC + Tunnel]
    Upstream -->|No| Done[Done]
    Tunnel --> Done

Simplest

  1. Deploy Worker
  2. Keep workers.dev enabled
  3. Enable Cloudflare Access on workers.dev
  4. Policy: Allow → Emails → your email
  5. Set preview_urls = false
  6. Validate Cf-Access-Jwt-Assertion in the Worker
  7. Use cloudflared access login/curl when calling it from terminal

Best long-term

  1. Attach a Custom Domain
  2. Protect it with Cloudflare Access
  3. Set workers_dev = false
  4. Set preview_urls = false
  5. Add:
    • Allow policy for your email
    • Service Auth policy for a service token
  6. Validate the Access JWT in the Worker
  7. If the Worker needs private upstreams, connect them with Workers VPC + Tunnel

If another Worker is the caller

  1. Put the shared/internal API in its own Worker
  2. Call it through a Service Binding
  3. Keep the target Worker off the public internet if possible
  4. Only use Access on hostnames that humans or external programs must reach

11) Reference docs