Skip to content
Gary Wu
Go back

Securing Servers with Tailscale and Cloudflare

Edit page

Org Status: 🟑 Dormant Cloudflare: N/A Last Audited: 2026-04-28


You just deployed your app. It works. It’s on the public internet. Anyone in the world can hit it. Every port you opened, every endpoint you exposed, every admin panel you β€œmeant to lock down later” β€” all of it is reachable right now. The question is not whether you need to secure it. The question is how many layers deep you want to go.

This article walks through six layers of security, from basic firewall rules to a full zero-trust architecture where your origin server has no open ports, identity is verified at the edge, and your private tools are invisible to the public internet. Each layer builds on the last. You can stop at any layer that matches your threat model.

What you’ll learn:



Every Cloudflare Worker you deploy is immediately reachable at <name>.<subdomain>.workers.dev. Every VPS you spin up has a public IP. Every side project you launch has an admin panel, a health endpoint, and probably a debug route you forgot to remove.

Here is what β€œdeployed” actually means:

Your App
β”œβ”€β”€ Public API     β†’ reachable by anyone
β”œβ”€β”€ Admin Panel    β†’ reachable by anyone
β”œβ”€β”€ Health Check   β†’ reachable by anyone (leaking version info)
β”œβ”€β”€ Debug Routes   β†’ reachable by anyone
β”œβ”€β”€ SSH (port 22)  β†’ reachable by anyone
└── Database       β†’ hopefully not reachable (but maybe)

The default state of any deployed service is fully exposed. Security is not a feature you add. It is the absence of exposure you enforce.

What goes wrong

Credential stuffing: Automated bots try common username/password combinations against your login endpoints. If you have any user-facing auth, you are getting hit.

Port scanning: Tools like Shodan and Censys index every public IP on the internet. Your server is in their database within hours of going live. Every open port is cataloged.

API abuse: If your API has no rate limiting or authentication, someone will find it and use it. For AI-powered services, this means someone else’s workload on your bill.

Lateral movement: Once one service is compromised, attackers pivot to everything on the same network. Your development database, your admin panel, your monitoring dashboard.

Supply chain probing: Attackers probe your dependencies, your CDN configuration, your DNS records. They are looking for the weakest link, not the strongest.

Key insight: Security is not about making one thing impenetrable. It is about making the cost of attack higher than the value of what you are protecting, at every layer.

The traditional response to these threats is a patchwork of IP allowlists, firewall rules, and VPN tunnels. That patchwork is what we are going to replace.


Before we get to the good stuff, let’s acknowledge what most people start with and why it breaks down.

SSH key authentication

The absolute minimum. Disable password authentication and use key-based auth:

sudo vim /etc/ssh/sshd_config

PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no
MaxAuthTries 3

sudo systemctl restart sshd

Generate a key pair if you don’t have one:

ssh-keygen -t ed25519 -C "your-email@example.com"

ssh-copy-id -i ~/.ssh/id_ed25519.pub user@your-server-ip

UFW firewall rules

sudo ufw default deny incoming
sudo ufw default allow outgoing

sudo ufw allow 22/tcp

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

sudo ufw enable

sudo ufw status verbose

IP allowlisting

Restrict SSH to specific IPs:

sudo ufw allow from 203.0.113.50 to any port 22

sudo ufw delete allow 22/tcp

Why this breaks down

ProblemImpact
Your IP changes (coffee shop, travel, ISP reassignment)You lock yourself out of your own server
Team members have different IPsYou maintain a growing allowlist that’s always stale
VPN exit nodes change IPsYour β€œstatic” IP is not static
No identity β€” only network locationAnyone at that IP gets access, not just you
Manual managementEvery new server needs the same rules applied
No audit trailYou don’t know who connected, just which IP
IPv6 adds complexityDual-stack means double the rules to maintain

Key insight: IP-based security answers the question β€œwhere are you?” when the real question is β€œwho are you?” The entire zero-trust movement is about replacing network location with identity.

This is Layer 1. It is better than nothing. It is not good enough for anything you care about.


Tailscale is a mesh VPN built on WireGuard. It creates a private network (called a β€œtailnet”) across all your devices. Every device gets a stable IP address in the 100.x.y.z range. Traffic between devices is encrypted end-to-end using WireGuard. No central gateway, no hub-and-spoke topology β€” devices connect directly to each other.

What makes Tailscale different from traditional VPNs

Traditional VPN (Hub-and-Spoke):
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   Device A ──────► β”‚ VPN     β”‚ ──────► Device B
   Device C ──────► β”‚ Server  β”‚ ──────► Device D
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   All traffic routes through the central server.
   Single point of failure. Bottleneck. Latency.

Tailscale (Mesh):
   Device A ◄──────────────────────────► Device B
      β–²                                     β–²
      β”‚                                     β”‚
      β–Ό                                     β–Ό
   Device C ◄──────────────────────────► Device D

   Direct connections. No bottleneck. Lower latency.
   Coordination server only handles key exchange.

Tailscale’s coordination server (called the β€œcontrol plane”) never sees your traffic. It only facilitates the initial handshake between devices. After that, devices communicate directly using WireGuard tunnels. When direct connections aren’t possible (strict NATs, firewalls), traffic routes through Tailscale’s DERP (Designated Encrypted Relay for Packets) servers β€” still encrypted end-to-end.

Installation

brew install tailscale

curl -fsSL https://tailscale.com/install.sh | sh


sudo tailscale up

tailscale ip -4

tailscale status

After installation on multiple machines, every device can reach every other device by its Tailscale IP or MagicDNS hostname:

ssh user@100.64.0.2

ssh user@my-server.tail1234.ts.net

tailscale ping my-server

tailscale serve β€” Expose services within your tailnet

tailscale serve lets you share a local service with other devices on your tailnet. Only devices on your Tailscale network can reach it. The public internet cannot.

tailscale serve --https=443 localhost:3000


tailscale serve /home/user/reports/dashboard.html

tailscale serve text:"Service is running"

tailscale serve --bg --https=443 localhost:3000

tailscale serve status

tailscale serve --https=443 localhost:3000 off

tailscale serve reset

This is powerful for internal tools. Run a Grafana dashboard on port 3000, tailscale serve it, and every device on your tailnet can access it at https://your-machine.tail1234.ts.net. No port forwarding. No firewall rules. No public exposure.

tailscale funnel β€” Expose to the public internet

tailscale funnel is the opposite of serve. It exposes a local service to the entire internet through Tailscale’s edge network. Tailscale provisions a TLS certificate and proxies traffic to your machine.

tailscale funnel --https=443 localhost:3000


tailscale funnel --https=8443 localhost:8080

tailscale funnel --bg --https=443 localhost:3000

tailscale funnel status

tailscale funnel --https=443 localhost:3000 off

Key insight: tailscale serve = private (tailnet only). tailscale funnel = public (internet). Same syntax, fundamentally different security posture. Know which one you’re using.

ACL policies β€” Control who sees what

Tailscale’s access control lists define which devices can talk to which other devices. By default, all devices on your tailnet can reach all other devices. ACLs let you restrict that.

ACLs are defined in your tailnet policy file (managed in the Tailscale admin console):

{
  // Groups of users
  "groups": {
    "group:devs": ["alice@example.com", "bob@example.com"],
    "group:ops": ["carol@example.com"]
  },

  // Tags for devices
  "tagOwners": {
    "tag:server": ["group:ops"],
    "tag:monitoring": ["group:ops"],
    "tag:dev": ["group:devs"]
  },

  // Access control rules
  "acls": [
    // Ops can access everything
    {
      "action": "accept",
      "src": ["group:ops"],
      "dst": ["*:*"]
    },
    // Devs can access dev-tagged servers on web ports
    {
      "action": "accept",
      "src": ["group:devs"],
      "dst": ["tag:dev:80,443,3000,8080"]
    },
    // Devs can SSH to dev servers
    {
      "action": "accept",
      "src": ["group:devs"],
      "dst": ["tag:dev:22"]
    },
    // Everyone can access monitoring dashboards
    {
      "action": "accept",
      "src": ["autogroup:member"],
      "dst": ["tag:monitoring:443"]
    }
  ]
}

Key concepts in ACL policies:

Grants β€” The modern approach

Tailscale is moving toward grants, a more expressive access control system. Grants support everything ACLs do plus additional capabilities like application-level access:

{
  "grants": [
    {
      "src": ["group:devs"],
      "dst": ["tag:server"],
      "ip": ["22", "80", "443"]
    },
    {
      "src": ["group:ops"],
      "dst": ["tag:server"],
      "app": {
        "tailscale.com/cap/tailscale-ssh": [{
          "users": ["root", "deploy"]
        }]
      }
    }
  ]
}

What Layer 2 gives you

After setting up Tailscale:

Your Infrastructure
β”œβ”€β”€ Public API        β†’ still on public internet (unchanged)
β”œβ”€β”€ Admin Panel       β†’ only accessible via Tailscale (100.x.y.z)
β”œβ”€β”€ Monitoring        β†’ only accessible via Tailscale
β”œβ”€β”€ SSH               β†’ only accessible via Tailscale
β”œβ”€β”€ Development DBs   β†’ only accessible via Tailscale
└── Inter-service     β†’ direct WireGuard tunnels

You’ve moved your internal tools off the public internet. But your public-facing services still need protection. That’s what the next layers are for.


Cloudflare sits between your users and your origin server. When someone visits your domain, their request goes to Cloudflare’s edge network first. Cloudflare handles TLS termination, DDoS protection, caching, and WAF rules. Then it forwards the request to your origin.

For Cloudflare Workers and Pages, there is no traditional β€œorigin server” β€” your code runs at Cloudflare’s edge. But the same principles apply: Cloudflare is the front door, and you control what gets through.

DNS setup β€” Proxied mode

When you add a domain to Cloudflare, you point your DNS to Cloudflare’s nameservers. Each DNS record can be either proxied (orange cloud) or DNS only (gray cloud):

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ DNS Records                                          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Type     β”‚ Name     β”‚ Content          β”‚ Proxy      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ A        β”‚ app      β”‚ 203.0.113.50     β”‚ Proxied ☁  β”‚
β”‚ CNAME    β”‚ api      β”‚ app.workers.dev  β”‚ Proxied ☁  β”‚
β”‚ A        β”‚ ssh      β”‚ 203.0.113.50     β”‚ DNS Only   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Proxied = traffic goes through Cloudflare            β”‚
β”‚ DNS Only = traffic goes direct to origin             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Proxied mode gives you:

Cloudflare Workers β€” Your API at the edge

For a Cloudflare Workers stack, your API runs at the edge already. There is no origin server to protect in the traditional sense. But you still need to control access:

// src/index.ts β€” A Hono app on Cloudflare Workers
import { Hono } from "hono";
import { cors } from "hono/cors";

interface Env {
  DB: D1Database;
  KV: KVNamespace;
  AUTH_SECRET: string;
}

const app = new Hono<{ Bindings: Env }>();

// Public routes β€” no auth
app.get("/health", (c) => c.json({ status: "ok" }));

// API routes β€” auth required (we'll add middleware in Layer 4)
app.get("/v1/users", async (c) => {
  const users = await c.env.DB.prepare("SELECT id, name FROM users").all();
  return c.json(users.results);
});

app.post("/v1/users", async (c) => {
  const body = await c.req.json();
  await c.env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)")
    .bind(body.name, body.email)
    .run();
  return c.json({ success: true }, 201);
});

export default app;

Cloudflare Pages β€” Your frontend

// functions/api/health.ts β€” Pages Function
export const onRequestGet: PagesFunction = async () => {
  return new Response(JSON.stringify({ status: "ok" }), {
    headers: { "Content-Type": "application/json" },
  });
};

SSL/TLS modes

Set your SSL/TLS mode to Full (strict) in the Cloudflare dashboard. This ensures:

  1. Browser to Cloudflare: encrypted (Cloudflare’s certificate)
  2. Cloudflare to origin: encrypted (origin must have a valid certificate)

For Workers and Pages, this is handled automatically. For traditional origins:

SSL/TLS Modes:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Mode         β”‚ What happens                                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Off          β”‚ No encryption. Never use this.                β”‚
│ Flexible     │ Browser→CF encrypted, CF→Origin unencrypted. │
β”‚              β”‚ Dangerous. Gives false sense of security.     β”‚
β”‚ Full         β”‚ Both hops encrypted. Origin cert not verified.β”‚
β”‚ Full(Strict) β”‚ Both hops encrypted. Origin cert verified.    β”‚
β”‚              β”‚ This is what you want.                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

What Layer 3 gives you

User Request β†’ Cloudflare Edge β†’ Your App
                    β”‚
                    β”œβ”€β”€ DDoS protection
                    β”œβ”€β”€ TLS termination
                    β”œβ”€β”€ WAF rules
                    β”œβ”€β”€ Bot detection
                    β”œβ”€β”€ Rate limiting
                    └── IP masking

Your origin IP is hidden. Attacks hit Cloudflare, not you.
But authentication is still YOUR problem.

Cloudflare stops volumetric attacks and filters obvious bad traffic. But it does not know who your users are. A valid-looking HTTP request from a real browser passes right through. That’s what Layer 4 solves.


Cloudflare Access is part of Cloudflare’s Zero Trust platform (formerly β€œCloudflare for Teams”). It puts an identity layer in front of your applications. Before any request reaches your app, Cloudflare Access checks: β€œIs this person allowed to access this?”

How it works

User Request
    β”‚
    β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Cloudflare Edge                              β”‚
β”‚                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚ Access Policy │───►│ Identity Provider β”‚   β”‚
β”‚  β”‚ Engine        │◄───│ (Google, GitHub,  β”‚   β”‚
β”‚  β”‚               β”‚    β”‚  email OTP, etc.) β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚         β”‚                                    β”‚
β”‚    Allow?β”‚                                   β”‚
β”‚    β”Œβ”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”                             β”‚
β”‚    β”‚ Yes β”‚ No  β”‚                             β”‚
β”‚    β””β”€β”€β”¬β”€β”€β”΄β”€β”€β”¬β”€β”€β”˜                             β”‚
β”‚       β”‚     β”‚                                β”‚
β”‚       β–Ό     β–Ό                                β”‚
β”‚    Forward  Return                           β”‚
β”‚    request  403                              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚
        β–Ό
    Your App (Worker/Pages/Origin)
    receives request + JWT in headers

When a user hits an Access-protected URL:

  1. Cloudflare checks for an existing session (JWT in CF_Authorization cookie)
  2. If no session: redirect to the identity provider login page
  3. User authenticates with their IdP (Google, GitHub, email OTP, etc.)
  4. Cloudflare issues a JWT and sets it as a cookie
  5. The request proceeds to your app with the JWT in the Cf-Access-Jwt-Assertion header
  6. Your app can validate the JWT to confirm the request came through Access

Setting up an Access Application

Step 1: Enable Cloudflare Zero Trust

Go to the Cloudflare Zero Trust dashboard. Create a team name (this becomes <team>.cloudflareaccess.com).

Step 2: Configure an Identity Provider

Navigate to Settings > Authentication > Login methods and add an IdP:

One-Time PIN (Email OTP) β€” simplest, no external IdP needed:

Users receive a 6-digit code via email. No third-party setup required.
Useful for: small teams, quick prototyping, non-technical stakeholders.

Google β€” requires a Google Cloud OAuth client:

1. Create OAuth client in Google Cloud Console
2. Set authorized redirect URI to:
   https://<team>.cloudflareaccess.com/cdn-cgi/access/callback
3. Enter Client ID and Client Secret in CF Zero Trust dashboard

GitHub β€” requires a GitHub OAuth App:

1. Create OAuth App at github.com/settings/developers
2. Set authorization callback URL to:
   https://<team>.cloudflareaccess.com/cdn-cgi/access/callback
3. Enter Client ID and Client Secret in CF Zero Trust dashboard

Step 3: Create an Access Application

Navigate to Access > Applications > Add an application:

Application Configuration:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Application name: Internal API             β”‚
β”‚ Session duration: 24 hours                 β”‚
β”‚                                            β”‚
β”‚ Application domain:                        β”‚
β”‚   Subdomain: api                           β”‚
β”‚   Domain: yourdomain.com                   β”‚
β”‚   Path: /admin/*                           β”‚
β”‚                                            β”‚
β”‚ Application type: Self-hosted              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 4: Create an Access Policy

Policies define who can access your application:

Policy: Allow Team Members
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Action: Allow                              β”‚
β”‚                                            β”‚
β”‚ Include:                                   β”‚
β”‚   Rule type: Emails ending in              β”‚
β”‚   Value: @yourcompany.com                  β”‚
β”‚                                            β”‚
β”‚ Require (optional):                        β”‚
β”‚   Rule type: Country                       β”‚
β”‚   Value: United States                     β”‚
β”‚                                            β”‚
β”‚ Exclude (optional):                        β”‚
β”‚   Rule type: Email                         β”‚
β”‚   Value: contractor@yourcompany.com        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Policy rule types include:

Rule TypeExampleUse Case
Emailsalice@company.comSpecific individuals
Emails ending in@company.comEntire organization
Identity provider groupsengineeringTeam-based access
IP ranges203.0.113.0/24Office networks
CountryUS, CAGeographic restrictions
Service Token(see below)Machine-to-machine
Everyone*Public with auth (e.g., require MFA for all)

The JWT β€” What your app receives

After authentication, every request to your app includes:

Headers:
  Cf-Access-Jwt-Assertion: eyJhbGciOiJSUzI1NiIs...
  Cookie: CF_Authorization=eyJhbGciOiJSUzI1NiIs...

The JWT payload contains:

{
  "aud": ["32eafc7626e974616deaf0dc3ce63d7bcbed58a2731bb"],
  "email": "alice@company.com",
  "exp": 1711036800,
  "iat": 1711000000,
  "nbf": 1711000000,
  "iss": "https://your-team.cloudflareaccess.com",
  "type": "app",
  "identity_nonce": "abc123",
  "sub": "user-uuid-here",
  "country": "US"
}

Key insight: Cloudflare Access does not replace application-level auth. It adds a layer in front of it. Your app should still validate the JWT to ensure the request genuinely came through Access and wasn’t crafted by someone who guessed your origin URL.

What Layer 4 gives you

Before Cloudflare Access:
  Anyone with the URL β†’ Your App

After Cloudflare Access:
  Anyone with the URL β†’ Identity Check β†’ Only authenticated users β†’ Your App

The login page is at the edge. Your app never sees unauthenticated requests.

For Cloudflare Workers specifically, this means your Worker only processes requests from users who have already proven their identity. Your /admin panel, your internal API, your debug endpoints β€” all protected by identity, not IP addresses.


This is where things get interesting. Tailscale can act as an OIDC identity provider for Cloudflare Access. The result: if you’re on the Tailscale network, you’re already authenticated. Cloudflare Access trusts Tailscale’s identity assertion. No additional login page, no email OTP, no Google OAuth dance.

Why this matters

Without this integration:

  1. You access an internal tool
  2. Cloudflare Access shows a login page
  3. You authenticate via Google/GitHub/email
  4. You get access

With Tailscale as IdP:

  1. You access an internal tool
  2. Cloudflare Access checks your Tailscale identity (already authenticated)
  3. You get access immediately (or see a one-click consent screen)

For a solo developer or small team that’s already on Tailscale, this eliminates the friction of re-authenticating for every Access-protected app.

Setting up tsidp

tsidp is Tailscale’s lightweight OIDC/OAuth Identity Provider server. It runs as a node on your tailnet and issues OIDC tokens based on your Tailscale identity.

Step 1: Deploy tsidp on your tailnet

services:
  tsidp:
    image: tailscale/tsidp:latest
    container_name: tsidp
    restart: unless-stopped
    environment:
      - TAILSCALE_USE_WIP_CODE=1
      - TS_STATE_DIR=/data
      - TS_HOSTNAME=idp
      - TSIDP_ENABLE_STS=1
      - TS_AUTHKEY=${TS_AUTHKEY}
    volumes:
      - tsidp-data:/data

volumes:
  tsidp-data:
TS_AUTHKEY=tskey-auth-abc123-xyz789 docker compose up -d

Step 2: Configure Tailscale grants for tsidp

In your Tailscale admin console, add grants for the tsidp capability:

{
  "grants": [
    {
      "src": ["autogroup:admin"],
      "dst": ["tag:idp"],
      "app": {
        "tailscale.com/cap/tsidp": [{
          "allow_admin_ui": true,
          "allow_dcr": true
        }]
      }
    }
  ]
}

Step 3: Register Cloudflare Access as an OAuth client

Access the tsidp admin UI at https://idp.your-tailnet.ts.net and register a new OAuth client:

Client Registration:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client Name: Cloudflare Access                  β”‚
β”‚ Redirect URI:                                   β”‚
β”‚   https://<team>.cloudflareaccess.com/          β”‚
β”‚   cdn-cgi/access/callback                       β”‚
β”‚                                                 β”‚
β”‚ You'll receive:                                 β”‚
β”‚   Client ID: tsidp_xxxxxxxxxxxxx                β”‚
β”‚   Client Secret: tsidp_secret_xxxxxxxxxxxxx     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 4: Add Tailscale as an IdP in Cloudflare Zero Trust

In the Cloudflare Zero Trust dashboard, navigate to Settings > Authentication > Login methods > Add new > OpenID Connect:

OIDC Configuration:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Name: Tailscale                                       β”‚
β”‚ App ID: tsidp_xxxxxxxxxxxxx                           β”‚
β”‚ Client Secret: tsidp_secret_xxxxxxxxxxxxx             β”‚
β”‚                                                       β”‚
β”‚ Auth URL:                                             β”‚
β”‚   https://idp.your-tailnet.ts.net/authorize           β”‚
β”‚                                                       β”‚
β”‚ Token URL:                                            β”‚
β”‚   https://idp.your-tailnet.ts.net/token               β”‚
β”‚                                                       β”‚
β”‚ Certificate URL (JWKS):                               β”‚
β”‚   https://idp.your-tailnet.ts.net/.well-known/jwks    β”‚
β”‚                                                       β”‚
β”‚ PKCE: Enabled                                         β”‚
β”‚                                                       β”‚
β”‚ Claims:                                               β”‚
β”‚   Email: email                                        β”‚
β”‚   Name:  name                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Step 5: Create Access policies using Tailscale identity

Now when you create Access policies, you can select β€œTailscale” as the identity provider. Users on your tailnet who are authenticated with Tailscale will pass through without a separate login.

Alternative: Use Tailscale Funnel to expose tsidp publicly

If Cloudflare Access needs to reach tsidp from the public internet (which it does, since the callback happens in the user’s browser):

tailscale funnel --bg --https=443 localhost:443

Key insight: tsidp bridges two security domains. Tailscale handles device identity (β€œthis person is on the tailnet and authenticated with SSO”). Cloudflare Access handles request-level identity (β€œthis request is from an authenticated user”). tsidp connects the two so your Tailscale identity flows through to Cloudflare.

What Layer 5 gives you

Tailscale User on the Tailnet:
  Request β†’ CF Edge β†’ Tailscale IdP check β†’ Already authenticated β†’ Your App
  (seamless, no login page)

Non-Tailscale User:
  Request β†’ CF Edge β†’ Login page (other IdP) β†’ Authenticate β†’ Your App
  (standard flow)

Result: Tailscale users get VIP access. Everyone else uses normal auth.

Cloudflare Tunnel (powered by cloudflared) creates an outbound-only connection from your server to Cloudflare’s edge. Your server initiates the connection β€” no inbound ports need to be open. No firewall rules to manage. Your server is invisible to port scanners.

How Cloudflare Tunnel works

Without Tunnel:
  User β†’ Cloudflare Edge β†’ Your Server (port 443 open to internet)
                            β–²
                            β”‚ Port 443 must be open
                            β”‚ Origin IP discoverable

With Tunnel:
  User β†’ Cloudflare Edge ◄──── cloudflared ──── Your Server
                            β”‚
                            β”‚ Outbound connection only
                            β”‚ No open ports
                            β”‚ Origin IP invisible

cloudflared runs on your server and establishes a persistent outbound connection to Cloudflare. Cloudflare routes incoming requests through this tunnel. Your server never opens a port to the internet.

Setting up Cloudflare Tunnel

Step 1: Install cloudflared

brew install cloudflared

curl -L --output cloudflared.deb \
  https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb

cloudflared tunnel login

Step 2: Create a tunnel

cloudflared tunnel create my-app-tunnel


cloudflared tunnel list

Step 3: Configure ingress rules

Create a configuration file at ~/.cloudflared/config.yml:

tunnel: my-app-tunnel
credentials-file: /home/user/.cloudflared/<TUNNEL_UUID>.json

ingress:
  # Route app.yourdomain.com to your local web server
  - hostname: app.yourdomain.com
    service: http://localhost:3000

  # Route api.yourdomain.com to your API
  - hostname: api.yourdomain.com
    service: http://localhost:8080

  # Route grafana.yourdomain.com to Grafana
  - hostname: grafana.yourdomain.com
    service: http://localhost:3001

  # Catch-all rule (required β€” must be last)
  - service: http_status:404

Step 4: Create DNS records

cloudflared tunnel route dns my-app-tunnel app.yourdomain.com
cloudflared tunnel route dns my-app-tunnel api.yourdomain.com
cloudflared tunnel route dns my-app-tunnel grafana.yourdomain.com

This creates CNAME records pointing to <TUNNEL_UUID>.cfargotunnel.com.

Step 5: Run the tunnel

cloudflared tunnel run my-app-tunnel

sudo cloudflared service install
sudo systemctl start cloudflared
sudo systemctl enable cloudflared

Combining with Tailscale

Here’s where the architecture becomes powerful. Your server is on the Tailscale network. cloudflared runs on that server. The server has no open ports to the public internet.

The Full Architecture:

Internet Users:
  User β†’ CF Edge β†’ CF Access (auth check) β†’ CF Tunnel β†’ Your Server
                                                          (no open ports)

Internal Users (on Tailscale):
  You β†’ Tailscale β†’ Your Server (direct, via 100.x.y.z)
                     (WireGuard encrypted)

Machine-to-Machine:
  Worker A β†’ CF Access (service token) β†’ CF Tunnel β†’ Your Server
  Worker B β†’ Tailscale β†’ Your Server

Your server is accessible two ways:

  1. From the internet: through Cloudflare Tunnel (with Access auth)
  2. From your tailnet: directly via Tailscale (no Cloudflare involved)

Neither path requires an open port.

Running cloudflared on a Tailscale node

The cloudflared process on your server can also route to other services on your Tailscale network:

tunnel: my-tunnel
credentials-file: /home/user/.cloudflared/<UUID>.json

ingress:
  # Route to a service on another Tailscale node
  - hostname: db-admin.yourdomain.com
    service: http://100.64.0.3:8080   # pgAdmin on another Tailscale node

  # Route to monitoring on yet another node
  - hostname: monitoring.yourdomain.com
    service: http://100.64.0.5:3000   # Grafana on monitoring node

  # Local service
  - hostname: app.yourdomain.com
    service: http://localhost:3000

  - service: http_status:404

This means one cloudflared instance can expose services running across your entire Tailscale network through Cloudflare’s edge. Each hostname gets its own Access policy. The services themselves are only reachable via Tailscale β€” no public IPs, no open ports.

Key insight: Cloudflare Tunnel makes your origin invisible. Tailscale makes your network private. Together, you get a setup where there are literally no open ports on any machine, and the only way in is through authenticated, encrypted channels β€” either Cloudflare Access or Tailscale.

What Layer 6 gives you

Your Server:
  Open ports to internet: 0
  Open ports to Tailscale: as needed (via Tailscale ACLs)
  Accessible from internet: only through CF Tunnel + CF Access
  Accessible from tailnet: directly via Tailscale IP

No port scanning. No origin IP exposure. No firewall rules.
Security by architecture, not by configuration.

When Cloudflare Access sits in front of your Worker, Access adds a JWT to every request. Your Worker should validate this JWT to ensure the request genuinely came through Access.

This is defense in depth. Even if someone discovers your *.workers.dev URL (which bypasses your custom domain and its Access policy), they cannot forge a valid JWT.

Full implementation with jose

// src/middleware/access-auth.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import type { Context, Next } from "hono";

interface AccessJwtPayload extends JWTPayload {
  email: string;
  type: string;
  identity_nonce: string;
  country?: string;
}

interface AccessAuthEnv {
  TEAM_DOMAIN: string;    // https://your-team.cloudflareaccess.com
  POLICY_AUD: string;     // Your application's AUD tag
}

// Cache the JWKS to avoid fetching on every request
let jwksCache: ReturnType<typeof createRemoteJWKSet> | null = null;

function getJWKS(teamDomain: string) {
  if (!jwksCache) {
    jwksCache = createRemoteJWKSet(
      new URL(`${teamDomain}/cdn-cgi/access/certs`)
    );
  }
  return jwksCache;
}

export function validateAccessJwt(env: AccessAuthEnv) {
  return async (c: Context, next: Next) => {
    // Prefer the header over the cookie
    const token = c.req.header("Cf-Access-Jwt-Assertion");

    if (!token) {
      return c.json(
        { error: "Missing Access JWT" },
        { status: 401 }
      );
    }

    try {
      const jwks = getJWKS(env.TEAM_DOMAIN);

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

      const accessPayload = payload as AccessJwtPayload;

      // Attach user identity to the request context
      c.set("userEmail", accessPayload.email);
      c.set("userCountry", accessPayload.country);
      c.set("accessPayload", accessPayload);

      await next();
    } catch (err) {
      console.error("JWT validation failed:", err);
      return c.json(
        { error: "Invalid Access JWT" },
        { status: 403 }
      );
    }
  };
}

Using the middleware in your Hono app

// src/index.ts
import { Hono } from "hono";
import { validateAccessJwt } from "./middleware/access-auth";

interface Env {
  DB: D1Database;
  TEAM_DOMAIN: string;
  POLICY_AUD: string;
  AUTH_SECRET: string;
}

const app = new Hono<{ Bindings: Env }>();

// Public routes β€” no auth
app.get("/health", (c) => c.json({ status: "ok" }));

// Protected routes β€” require Access JWT
app.use("/admin/*", (c, next) => {
  const middleware = validateAccessJwt({
    TEAM_DOMAIN: c.env.TEAM_DOMAIN,
    POLICY_AUD: c.env.POLICY_AUD,
  });
  return middleware(c, next);
});

app.get("/admin/users", async (c) => {
  const email = c.get("userEmail");
  console.log(`Admin request from: ${email}`);

  const users = await c.env.DB.prepare("SELECT id, name, email FROM users").all();
  return c.json(users.results);
});

app.get("/admin/whoami", (c) => {
  return c.json({
    email: c.get("userEmail"),
    country: c.get("userCountry"),
  });
});

export default app;

Wrangler configuration

// wrangler.jsonc
{
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "vars": {
    "TEAM_DOMAIN": "https://your-team.cloudflareaccess.com",
    "POLICY_AUD": "32eafc7626e974616deaf0dc3ce63d7bcbed58a2731bb"
  },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-app-db",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
  ]
}

Key insight: Always validate the Cf-Access-Jwt-Assertion header, not the CF_Authorization cookie. The cookie is not guaranteed to be passed in all contexts (e.g., API calls from service tokens). The header is always present when Access is configured.

Key rotation

Cloudflare rotates signing keys every 6 weeks by default. Previous keys remain valid for 7 days after rotation. Using createRemoteJWKSet from jose handles this automatically β€” it fetches the current keys from the certs endpoint and caches them.

If you’re caching aggressively (as the example above does), consider adding a fallback that clears the JWKS cache and retries when validation fails:

export function validateAccessJwtWithRotation(env: AccessAuthEnv) {
  return async (c: Context, next: Next) => {
    const token = c.req.header("Cf-Access-Jwt-Assertion");
    if (!token) {
      return c.json({ error: "Missing Access JWT" }, { status: 401 });
    }

    try {
      const jwks = getJWKS(env.TEAM_DOMAIN);
      const { payload } = await jwtVerify(token, jwks, {
        issuer: env.TEAM_DOMAIN,
        audience: env.POLICY_AUD,
      });
      c.set("userEmail", (payload as AccessJwtPayload).email);
      await next();
    } catch (firstError) {
      // Key rotation may have happened β€” clear cache and retry
      jwksCache = null;
      try {
        const jwks = getJWKS(env.TEAM_DOMAIN);
        const { payload } = await jwtVerify(token, jwks, {
          issuer: env.TEAM_DOMAIN,
          audience: env.POLICY_AUD,
        });
        c.set("userEmail", (payload as AccessJwtPayload).email);
        await next();
      } catch (secondError) {
        console.error("JWT validation failed after retry:", secondError);
        return c.json({ error: "Invalid Access JWT" }, { status: 403 });
      }
    }
  };
}

Not every request comes from a human sitting at a browser. CI/CD pipelines, cron jobs, monitoring systems, and other Workers need to call your Access-protected APIs. Cloudflare Access Service Tokens solve this.

Creating a service token

In the Cloudflare Zero Trust dashboard, navigate to Access controls > Service credentials > Service Tokens:

Create Service Token:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Name: CI Pipeline                                 β”‚
β”‚ Duration: 1 year                                  β”‚
β”‚                                                   β”‚
β”‚ ⚠️  SAVE THESE NOW β€” shown only once:             β”‚
β”‚                                                   β”‚
β”‚ Client ID:     abc123.access                      β”‚
β”‚ Client Secret: xyz789secret...                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Using service tokens in requests

curl -H "CF-Access-Client-Id: abc123.access" \
     -H "CF-Access-Client-Secret: xyz789secret..." \
     https://api.yourdomain.com/v1/data

Service token policy in Access

Create a policy with the Service Auth action:

Policy: Allow CI Pipeline
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Action: Service Auth                       β”‚
β”‚                                            β”‚
β”‚ Include:                                   β”‚
β”‚   Rule type: Service Token                 β”‚
β”‚   Value: CI Pipeline                       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Using service tokens from a Cloudflare Worker

// src/clients/protected-api.ts
interface ProtectedApiEnv {
  TARGET_API_URL: string;
  CF_ACCESS_CLIENT_ID: string;
  CF_ACCESS_CLIENT_SECRET: string;
}

export async function callProtectedApi(
  env: ProtectedApiEnv,
  path: string,
  options: RequestInit = {}
): Promise<Response> {
  const url = `${env.TARGET_API_URL}${path}`;

  const headers = new Headers(options.headers);
  headers.set("CF-Access-Client-Id", env.CF_ACCESS_CLIENT_ID);
  headers.set("CF-Access-Client-Secret", env.CF_ACCESS_CLIENT_SECRET);
  headers.set("Content-Type", "application/json");

  return fetch(url, {
    ...options,
    headers,
  });
}

// Usage in a Worker
export default {
  async scheduled(event: ScheduledEvent, env: ProtectedApiEnv) {
    const response = await callProtectedApi(env, "/v1/sync", {
      method: "POST",
      body: JSON.stringify({ trigger: "cron" }),
    });

    if (!response.ok) {
      console.error(`Sync failed: ${response.status}`);
    }
  },
} satisfies ExportedHandler<ProtectedApiEnv>;

Storing service token credentials

npx wrangler secret put CF_ACCESS_CLIENT_ID

npx wrangler secret put CF_ACCESS_CLIENT_SECRET

Key insight: Service tokens are for machines, not humans. They bypass the login page entirely. Create separate tokens for each service (CI, monitoring, cron) so you can revoke individually without disrupting everything. Set expiration dates β€” don’t create eternal tokens.


In practice, you need multiple auth mechanisms on the same Worker. Public endpoints, Access-protected user endpoints, and service-to-service endpoints often coexist. Here’s a pattern that handles all three:

// src/middleware/auth.ts
import { Hono } from "hono";
import type { Context, Next } from "hono";

interface Env {
  DB: D1Database;
  AUTH_SECRET: string;
  TEAM_DOMAIN: string;
  POLICY_AUD: string;
}

// Tier 1: Service-to-service auth (internal Workers)
function validateServiceKey(secret: string) {
  return async (c: Context, next: Next) => {
    const key = c.req.header("X-Service-Key");
    if (!key || key !== secret) {
      return c.json({ error: "Unauthorized" }, 401);
    }
    c.set("authType", "service");
    await next();
  };
}

// Tier 2: Cloudflare Access JWT (human users via browser)
function validateAccessUser(teamDomain: string, policyAud: string) {
  return async (c: Context, next: Next) => {
    const token = c.req.header("Cf-Access-Jwt-Assertion");
    if (!token) {
      return c.json({ error: "Missing Access JWT" }, 401);
    }

    try {
      // Validate JWT (see full implementation above)
      const { payload } = await verifyJwt(token, teamDomain, policyAud);
      c.set("authType", "access-user");
      c.set("userEmail", payload.email);
      await next();
    } catch {
      return c.json({ error: "Invalid JWT" }, 403);
    }
  };
}

// Tier 3: Access Service Token (machine-to-machine via CF Access)
// No additional validation needed β€” if the request reached your Worker
// through an Access policy with Service Auth, the token was already
// validated at the edge. The Cf-Access-Jwt-Assertion header is present
// and can be validated the same way as user JWTs.

// Flexible auth β€” accepts either service key OR Access JWT
function validateAnyAuth(env: Env) {
  return async (c: Context, next: Next) => {
    // Try service key first (fastest path)
    const serviceKey = c.req.header("X-Service-Key");
    if (serviceKey && serviceKey === env.AUTH_SECRET) {
      c.set("authType", "service");
      await next();
      return;
    }

    // Try Access JWT
    const accessToken = c.req.header("Cf-Access-Jwt-Assertion");
    if (accessToken) {
      try {
        const { payload } = await verifyJwt(
          accessToken,
          env.TEAM_DOMAIN,
          env.POLICY_AUD
        );
        c.set("authType", "access-user");
        c.set("userEmail", (payload as any).email);
        await next();
        return;
      } catch {
        // Fall through to 401
      }
    }

    return c.json({ error: "Unauthorized" }, 401);
  };
}

// Wire it up
const app = new Hono<{ Bindings: Env }>();

// Public β€” no auth
app.get("/health", (c) => c.json({ status: "ok" }));

// Internal API β€” service key only
app.use("/internal/*", (c, next) =>
  validateServiceKey(c.env.AUTH_SECRET)(c, next)
);

// Admin β€” Access JWT only (human users)
app.use("/admin/*", (c, next) =>
  validateAccessUser(c.env.TEAM_DOMAIN, c.env.POLICY_AUD)(c, next)
);

// API β€” either auth method
app.use("/v1/*", (c, next) =>
  validateAnyAuth(c.env)(c, next)
);

export default app;

This pattern separates concerns cleanly:

Route PatternAuth MethodWho Uses It
/healthNoneLoad balancers, uptime monitors
/internal/*Service keyOther Workers, cron jobs
/admin/*Access JWTHuman users via browser
/v1/*EitherBoth humans and machines

Every response from your Worker should include security headers. These prevent common attacks like clickjacking, XSS, and MIME sniffing:

// src/middleware/security-headers.ts
import type { Context, Next } from "hono";

export async function securityHeaders(c: Context, next: Next) {
  await next();

  // Prevent clickjacking
  c.header("X-Frame-Options", "DENY");

  // Prevent MIME type sniffing
  c.header("X-Content-Type-Options", "nosniff");

  // Control referrer information
  c.header("Referrer-Policy", "strict-origin-when-cross-origin");

  // Prevent XSS (mostly redundant with CSP but still recommended)
  c.header("X-XSS-Protection", "1; mode=block");

  // Content Security Policy
  c.header(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      "script-src 'self'",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-ancestors 'none'",
    ].join("; ")
  );

  // HSTS β€” enforce HTTPS
  c.header(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains; preload"
  );

  // Permissions Policy β€” disable browser features you don't use
  c.header(
    "Permissions-Policy",
    [
      "camera=()",
      "microphone=()",
      "geolocation=()",
      "payment=()",
    ].join(", ")
  );
}

// Usage
const app = new Hono();
app.use("*", securityHeaders);

API-specific security headers

For API-only Workers (no HTML rendering), use a simpler set:

// src/middleware/api-security-headers.ts
import type { Context, Next } from "hono";

export async function apiSecurityHeaders(c: Context, next: Next) {
  await next();

  c.header("X-Content-Type-Options", "nosniff");
  c.header("X-Frame-Options", "DENY");
  c.header("Cache-Control", "no-store");
  c.header("Content-Type", "application/json");

  // Remove server identification headers
  c.header("Server", "");
  c.header("X-Powered-By", "");
}

Tailscale SSH replaces traditional SSH key management. Instead of distributing SSH keys to every server and every team member, Tailscale authenticates SSH sessions using your Tailscale identity. No keys to manage, rotate, or revoke.

Enabling Tailscale SSH

sudo tailscale set --ssh

tailscale status

ACL policy for SSH access

Add SSH rules to your tailnet policy file:

{
  "ssh": [
    // Admins can SSH as any user, including root
    {
      "action": "accept",
      "src": ["autogroup:admin"],
      "dst": ["autogroup:self"],
      "users": ["autogroup:nonroot", "root"]
    },
    // Developers can SSH as non-root users only
    {
      "action": "accept",
      "src": ["group:devs"],
      "dst": ["tag:dev"],
      "users": ["autogroup:nonroot"]
    },
    // Require re-authentication for production servers
    {
      "action": "check",
      "src": ["group:devs"],
      "dst": ["tag:prod"],
      "users": ["autogroup:nonroot"]
    }
  ]
}

The "action": "check" mode requires the user to re-authenticate with their SSO provider before establishing the SSH connection. Use this for sensitive production access.

Using Tailscale SSH

ssh user@my-server.tail1234.ts.net

ssh user@100.64.0.2

Disabling traditional SSH

Once Tailscale SSH is working, you can close port 22 entirely:

sudo ufw delete allow 22/tcp

sudo systemctl stop sshd
sudo systemctl disable sshd

Your server now has zero open ports. Tailscale SSH works over the WireGuard tunnel β€” it doesn’t need port 22.

Key insight: Traditional SSH key management is a security liability. Keys are shared, copied, never rotated, and hard to revoke. Tailscale SSH ties access to identity β€” revoke a person’s Tailscale access and they lose SSH access to every machine simultaneously. No key cleanup needed.


Split-horizon means the same infrastructure presents different surfaces depending on where you are. Public users see a limited, authenticated view. Internal users on the tailnet see everything.

// src/middleware/split-horizon.ts
import type { Context, Next } from "hono";

interface SplitHorizonEnv {
  AUTH_SECRET: string;
  TEAM_DOMAIN: string;
  POLICY_AUD: string;
  ENVIRONMENT: string;
}

// Detect if the request is from an internal source
function isInternalRequest(c: Context<{ Bindings: SplitHorizonEnv }>): boolean {
  // Check for service key (Worker-to-Worker)
  const serviceKey = c.req.header("X-Service-Key");
  if (serviceKey && serviceKey === c.env.AUTH_SECRET) {
    return true;
  }

  // Check Cloudflare headers for internal networks
  const cfConnecting = c.req.header("CF-Connecting-IP");
  const isCloudflareWorker = c.req.header("CF-Worker") !== undefined;

  return isCloudflareWorker;
}

// Middleware that exposes different routes based on access level
export function splitHorizon() {
  return async (c: Context<{ Bindings: SplitHorizonEnv }>, next: Next) => {
    c.set("isInternal", isInternalRequest(c));
    await next();
  };
}

// Usage in routes
const app = new Hono<{ Bindings: SplitHorizonEnv }>();

app.use("*", splitHorizon());

// Public health check β€” minimal info
app.get("/health", (c) => {
  if (c.get("isInternal")) {
    // Internal: full diagnostics
    return c.json({
      status: "ok",
      version: "2.3.1",
      environment: c.env.ENVIRONMENT,
      uptime: process.uptime?.() ?? "N/A",
      d1_status: "connected",
      queue_depth: 42,
    });
  }
  // Public: just a heartbeat
  return c.json({ status: "ok" });
});

// Debug routes β€” internal only
app.get("/debug/*", (c) => {
  if (!c.get("isInternal")) {
    return c.json({ error: "Not found" }, 404);
  }
  // ... debug information
  return c.json({ debug: true });
});

1. Rate limiting by identity

// Rate limit based on the authenticated user's email, not IP
import type { Context, Next } from "hono";

export function rateLimitByIdentity(
  kv: KVNamespace,
  maxRequests: number,
  windowSeconds: number
) {
  return async (c: Context, next: Next) => {
    const email = c.get("userEmail") || "anonymous";
    const window = Math.floor(Date.now() / (windowSeconds * 1000));
    const key = `rl:${email}:${window}`;

    const current = parseInt((await kv.get(key)) || "0");
    if (current >= maxRequests) {
      return c.json(
        {
          error: "Rate limit exceeded",
          retryAfter: windowSeconds,
        },
        429
      );
    }

    await kv.put(key, String(current + 1), {
      expirationTtl: windowSeconds + 60,
    });

    c.header("X-RateLimit-Limit", String(maxRequests));
    c.header("X-RateLimit-Remaining", String(maxRequests - current - 1));

    await next();
  };
}

2. Tailscale status check script

#!/bin/bash

echo "=== Tailscale Network Audit ==="
echo ""

echo "Local node:"
tailscale status --self --json | jq '{
  hostname: .Self.HostName,
  tailscaleIP: .Self.TailscaleIPs[0],
  os: .Self.OS,
  online: .Self.Online,
  exitNode: .Self.ExitNode
}'

echo ""
echo "All peers:"
tailscale status --json | jq '.Peer | to_entries[] | {
  hostname: .value.HostName,
  ip: .value.TailscaleIPs[0],
  os: .value.OS,
  online: .value.Online,
  lastSeen: .value.LastSeen,
  exitNode: .value.ExitNode
}'

echo ""
echo "Active serve/funnel:"
tailscale serve status --json 2>/dev/null || echo "No active serve configurations"
tailscale funnel status --json 2>/dev/null || echo "No active funnel configurations"

3. Cloudflare Access logout handler

// src/routes/auth.ts β€” Handle Access logout
import { Hono } from "hono";

const auth = new Hono();

auth.get("/auth/logout", (c) => {
  // Clear the CF_Authorization cookie
  c.header(
    "Set-Cookie",
    "CF_Authorization=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax"
  );

  // Redirect to Access logout endpoint
  const teamDomain = c.env.TEAM_DOMAIN;
  return c.redirect(`${teamDomain}/cdn-cgi/access/logout`);
});

auth.get("/auth/whoami", (c) => {
  const email = c.get("userEmail");
  if (!email) {
    return c.json({ authenticated: false });
  }
  return c.json({
    authenticated: true,
    email,
    country: c.get("userCountry"),
  });
});

export { auth };

4. Origin IP protection check

// src/middleware/origin-protection.ts
// Ensure requests actually come through Cloudflare, not direct to origin
import type { Context, Next } from "hono";

// Cloudflare's IP ranges β€” update periodically from
// https://www.cloudflare.com/ips/
const CF_IPS_V4 = [
  "173.245.48.0/20",
  "103.21.244.0/22",
  "103.22.200.0/22",
  "103.31.4.0/22",
  "141.101.64.0/18",
  "108.162.192.0/18",
  "190.93.240.0/20",
  "188.114.96.0/20",
  "197.234.240.0/22",
  "198.41.128.0/17",
  "162.158.0.0/15",
  "104.16.0.0/13",
  "104.24.0.0/14",
  "172.64.0.0/13",
  "131.0.72.0/22",
];

function ipInCidr(ip: string, cidr: string): boolean {
  const [range, bits] = cidr.split("/");
  const mask = ~(2 ** (32 - parseInt(bits)) - 1);
  const ipNum = ip
    .split(".")
    .reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
  const rangeNum = range
    .split(".")
    .reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
  return (ipNum & mask) === (rangeNum & mask);
}

export async function requireCloudflare(c: Context, next: Next) {
  // On Workers, requests always come through CF β€” this is for traditional origins
  const connectingIp = c.req.header("CF-Connecting-IP");
  if (!connectingIp) {
    return c.json({ error: "Direct access forbidden" }, 403);
  }
  await next();
}

5. Secure environment variable validation

// src/utils/env-check.ts
// Validate all required secrets are present at startup

interface RequiredEnv {
  AUTH_SECRET: string;
  TEAM_DOMAIN: string;
  POLICY_AUD: string;
  DB: D1Database;
}

export function validateEnv(env: Record<string, unknown>): env is RequiredEnv {
  const required = ["AUTH_SECRET", "TEAM_DOMAIN", "POLICY_AUD", "DB"];
  const missing = required.filter((key) => !env[key]);

  if (missing.length > 0) {
    console.error(`Missing required env vars: ${missing.join(", ")}`);
    return false;
  }

  // Validate format
  if (typeof env.AUTH_SECRET === "string" && env.AUTH_SECRET.length < 32) {
    console.error("AUTH_SECRET too short β€” use at least 32 hex characters");
    return false;
  }

  if (
    typeof env.TEAM_DOMAIN === "string" &&
    !env.TEAM_DOMAIN.startsWith("https://")
  ) {
    console.error("TEAM_DOMAIN must start with https://");
    return false;
  }

  return true;
}

// Usage in your Worker entrypoint
export default {
  async fetch(request: Request, env: RequiredEnv): Promise<Response> {
    if (!validateEnv(env)) {
      return new Response("Misconfigured", { status: 500 });
    }
    // ... handle request
  },
} satisfies ExportedHandler<RequiredEnv>;

6. Cloudflare Tunnel health monitoring

#!/bin/bash

TUNNEL_NAME="my-app-tunnel"

echo "=== Tunnel Status ==="
cloudflared tunnel info "$TUNNEL_NAME"

echo ""
echo "=== Active Connections ==="
cloudflared tunnel info "$TUNNEL_NAME" --json | jq '.connections[] | {
  id: .id,
  origin_ip: .origin_ip,
  edge_ip: .edge_ip,
  protocol: .protocol,
  opened_at: .opened_at
}'

echo ""
echo "=== Tunnel Metrics ==="
curl -s http://localhost:2000/metrics | grep -E "^cloudflared_tunnel" | head -20

7. Tailscale device tagging automation

#!/bin/bash

tailscale set --advertise-tags=tag:server

tailscale set --advertise-tags=tag:dev

tailscale set --advertise-tags=tag:monitoring

tailscale set --advertise-tags=tag:server,tag:prod

tailscale status --self --json | jq '.Self.Tags'

8. Access policy testing with curl

#!/bin/bash

URL="https://api.yourdomain.com/v1/test"

echo "=== Test 1: No auth (should 302 to login) ==="
curl -s -o /dev/null -w "%{http_code}" "$URL"
echo ""

echo "=== Test 2: Service token (should 200) ==="
curl -s -o /dev/null -w "%{http_code}" \
  -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
  "$URL"
echo ""

echo "=== Test 3: Invalid service token (should 403) ==="
curl -s -o /dev/null -w "%{http_code}" \
  -H "CF-Access-Client-Id: invalid" \
  -H "CF-Access-Client-Secret: invalid" \
  "$URL"
echo ""

echo "=== Test 4: Internal service key (should 200) ==="
curl -s -o /dev/null -w "%{http_code}" \
  -H "X-Service-Key: $AUTH_SECRET" \
  "$URL"
echo ""

9. Conditional Access based on identity claims

// src/middleware/role-auth.ts
// Route access based on claims in the Access JWT
import type { Context, Next } from "hono";

const ADMIN_EMAILS = new Set([
  "alice@company.com",
  "bob@company.com",
]);

export function requireAdmin() {
  return async (c: Context, next: Next) => {
    const email = c.get("userEmail");
    if (!email || !ADMIN_EMAILS.has(email)) {
      return c.json(
        {
          error: "Forbidden",
          message: "Admin access required",
        },
        403
      );
    }
    await next();
  };
}

export function requireCountry(...countries: string[]) {
  const allowed = new Set(countries);
  return async (c: Context, next: Next) => {
    const country = c.get("userCountry");
    if (!country || !allowed.has(country)) {
      return c.json(
        {
          error: "Forbidden",
          message: "Access restricted by location",
        },
        403
      );
    }
    await next();
  };
}

// Usage
app.use("/admin/*", requireAdmin());
app.use("/us-only/*", requireCountry("US", "CA"));

10. Wrangler secret management script

#!/bin/bash

WORKER_NAME="my-api"

echo "Setting up secrets for $WORKER_NAME..."

AUTH_SECRET=$(openssl rand -hex 32)
echo "Generated AUTH_SECRET: $AUTH_SECRET"
echo "$AUTH_SECRET" | npx wrangler secret put AUTH_SECRET --name "$WORKER_NAME"

echo "Enter your team domain (e.g., https://your-team.cloudflareaccess.com):"
read -r TEAM_DOMAIN
echo "$TEAM_DOMAIN" | npx wrangler secret put TEAM_DOMAIN --name "$WORKER_NAME"

echo "Enter your Access application AUD tag:"
read -r POLICY_AUD
echo "$POLICY_AUD" | npx wrangler secret put POLICY_AUD --name "$WORKER_NAME"

echo ""
echo "Secrets configured. Verify with:"
echo "  npx wrangler secret list --name $WORKER_NAME"

Here’s a complete architecture for a production Cloudflare Workers stack with both public and private surfaces. This is based on a real setup running multiple brand sites, content pipelines, and internal tools.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        PUBLIC INTERNET                           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                  β”‚
β”‚  brand.com ─────► Cloudflare Pages (SSR frontend)                β”‚
β”‚                    β”œβ”€β”€ Static pages (cached at edge)             β”‚
β”‚                    β”œβ”€β”€ Dynamic pages (Pages Functions)           β”‚
β”‚                    └── No auth required (public content)         β”‚
β”‚                                                                  β”‚
β”‚  api.brand.com ──► Cloudflare Worker (API)                       β”‚
β”‚                    β”œβ”€β”€ /health β†’ public                          β”‚
β”‚                    β”œβ”€β”€ /v1/* β†’ public + rate limited             β”‚
β”‚                    └── /admin/* β†’ CF Access (email OTP)          β”‚
β”‚                                                                  β”‚
β”‚  dashboard.brand.com ─► Cloudflare Pages (admin dashboard)       β”‚
β”‚                         └── Entire app behind CF Access          β”‚
β”‚                             (Google SSO required)                β”‚
β”‚                                                                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                     CLOUDFLARE ZERO TRUST                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                  β”‚
β”‚  CF Access Policies:                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
β”‚  β”‚ Application       β”‚ Policy         β”‚ IdP                β”‚     β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€     β”‚
β”‚  β”‚ api/admin/*       β”‚ Email ending   β”‚ Email OTP          β”‚     β”‚
β”‚  β”‚                   β”‚ @company.com   β”‚                    β”‚     β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€     β”‚
β”‚  β”‚ dashboard.*       β”‚ Google group   β”‚ Google Workspace   β”‚     β”‚
β”‚  β”‚                   β”‚ "team"         β”‚                    β”‚     β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€     β”‚
β”‚  β”‚ api/internal/*    β”‚ Service Token  β”‚ Service Auth       β”‚     β”‚
β”‚  β”‚                   β”‚ "CI Pipeline"  β”‚                    β”‚     β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€     β”‚
β”‚  β”‚ monitoring.*      β”‚ Tailscale IdP  β”‚ tsidp (OIDC)       β”‚     β”‚
β”‚  β”‚                   β”‚                β”‚                    β”‚     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
β”‚                                                                  β”‚
β”‚  Service Tokens:                                                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”‚
β”‚  β”‚ Token Name        β”‚ Used By                          β”‚        β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€        β”‚
β”‚  β”‚ CI Pipeline       β”‚ GitHub Actions β†’ api/internal/*  β”‚        β”‚
β”‚  β”‚ Content Worker    β”‚ Content pipeline β†’ api/v1/posts  β”‚        β”‚
β”‚  β”‚ Monitoring        β”‚ Uptime checks β†’ api/health       β”‚        β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚
β”‚                                                                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                       TAILSCALE NETWORK                          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”‚
β”‚  β”‚ MacBook Pro  β”‚   β”‚ Windows PC  β”‚   β”‚ Linux Serverβ”‚           β”‚
β”‚  β”‚ 100.64.0.1   β”‚   β”‚ 100.64.0.2  β”‚   β”‚ 100.64.0.3  β”‚           β”‚
β”‚  β”‚              β”‚   β”‚             β”‚   β”‚              β”‚           β”‚
β”‚  β”‚ Development  β”‚   β”‚ Design      β”‚   β”‚ cloudflared  β”‚           β”‚
β”‚  β”‚ wrangler dev β”‚   β”‚ Testing     β”‚   β”‚ tsidp        β”‚           β”‚
β”‚  β”‚              β”‚   β”‚             β”‚   β”‚ Grafana      β”‚           β”‚
β”‚  β”‚ Tailscale    β”‚   β”‚ Tailscale   β”‚   β”‚ Tailscale    β”‚           β”‚
β”‚  β”‚ SSH client   β”‚   β”‚ SSH client  β”‚   β”‚ SSH server   β”‚           β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
β”‚         β”‚                  β”‚                  β”‚                   β”‚
β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β”‚
β”‚              WireGuard mesh (encrypted, direct)                  β”‚
β”‚                                                                  β”‚
β”‚  Internal tools (Tailscale only):                                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚
β”‚  β”‚ Grafana      β†’ tailscale serve on 100.64.0.3:3000    β”‚       β”‚
β”‚  β”‚ pgAdmin      β†’ tailscale serve on 100.64.0.3:8080    β”‚       β”‚
β”‚  β”‚ Log viewer   β†’ tailscale serve on 100.64.0.3:9090    β”‚       β”‚
β”‚  β”‚ tsidp        β†’ tailscale funnel (public for CF OIDC) β”‚       β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚
β”‚                                                                  β”‚
β”‚  Cloudflare Tunnel (from Linux server):                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚
β”‚  β”‚ monitoring.brand.com β†’ CF Tunnel β†’ 100.64.0.3:3000   β”‚       β”‚
β”‚  β”‚                         (Grafana, Access-protected)   β”‚       β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚
β”‚                                                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Data flow for each access pattern

Public user reads a blog post:

User β†’ Cloudflare CDN β†’ Pages (cached) β†’ Response
No auth. Cached at edge. Fast.

Public user calls the API:

User β†’ CF Edge β†’ Worker β†’ D1 β†’ Response
Rate limited. No auth required for public endpoints.

Admin accesses the dashboard:

Admin β†’ CF Edge β†’ Access (Google login) β†’ Pages β†’ API Worker β†’ D1
Identity verified at edge. JWT in headers. Worker validates JWT.

CI pipeline deploys content:

GitHub Actions β†’ CF Edge β†’ Access (service token) β†’ Worker β†’ D1/R2
Machine-to-machine. No login page. Service Auth policy.

Developer SSHes into the server:

Developer β†’ Tailscale β†’ 100.64.0.3 (Tailscale SSH)
No port 22 open. WireGuard encrypted. ACL-controlled.

Developer checks Grafana:

Developer β†’ Tailscale β†’ 100.64.0.3:3000 (tailscale serve)
Only visible on the tailnet. Not on the internet.
OR
Developer β†’ CF Edge β†’ Access (Tailscale IdP) β†’ CF Tunnel β†’ Grafana
Accessible from any browser, identity-verified.

Wrangler configuration for the API Worker

// wrangler.jsonc
{
  "name": "brand-api",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "routes": [
    {
      "pattern": "api.brand.com/*",
      "zone_name": "brand.com"
    }
  ],
  "vars": {
    "ENVIRONMENT": "production",
    "TEAM_DOMAIN": "https://your-team.cloudflareaccess.com"
  },
  // AUTH_SECRET and POLICY_AUD are set via `wrangler secret put`
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "brand-db",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
  ],
  "kv_namespaces": [
    {
      "binding": "CACHE",
      "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    },
    {
      "binding": "RATE_LIMIT",
      "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  ],
  "queues": {
    "producers": [
      {
        "binding": "EVENTS",
        "queue": "brand-events"
      }
    ]
  }
}

Complete Worker entrypoint

// src/index.ts β€” Production Worker with multi-tier auth
import { Hono } from "hono";
import { cors } from "hono/cors";
import { validateAccessJwtWithRotation } from "./middleware/access-auth";
import { securityHeaders } from "./middleware/security-headers";
import { rateLimitByIdentity } from "./middleware/rate-limit";
import { validateEnv } from "./utils/env-check";
import { auth } from "./routes/auth";

interface Env {
  DB: D1Database;
  CACHE: KVNamespace;
  RATE_LIMIT: KVNamespace;
  EVENTS: Queue;
  AUTH_SECRET: string;
  TEAM_DOMAIN: string;
  POLICY_AUD: string;
  ENVIRONMENT: string;
}

const app = new Hono<{ Bindings: Env }>();

// Global middleware
app.use("*", securityHeaders);
app.use(
  "*",
  cors({
    origin: ["https://brand.com", "https://dashboard.brand.com"],
    allowMethods: ["GET", "POST", "PUT", "DELETE"],
    allowHeaders: [
      "Content-Type",
      "X-Service-Key",
      "CF-Access-Client-Id",
      "CF-Access-Client-Secret",
    ],
    maxAge: 86400,
  })
);

// Public routes
app.get("/health", (c) => c.json({ status: "ok" }));
app.get("/robots.txt", (c) => c.text("User-agent: *\nDisallow: /"));

// Auth routes
app.route("/", auth);

// Service-to-service routes
app.use("/internal/*", async (c, next) => {
  const key = c.req.header("X-Service-Key");
  if (!key || key !== c.env.AUTH_SECRET) {
    return c.json({ error: "Unauthorized" }, 401);
  }
  await next();
});

// Admin routes β€” CF Access required
app.use("/admin/*", (c, next) => {
  const middleware = validateAccessJwtWithRotation({
    TEAM_DOMAIN: c.env.TEAM_DOMAIN,
    POLICY_AUD: c.env.POLICY_AUD,
  });
  return middleware(c, next);
});

// API routes β€” rate limited
app.use("/v1/*", (c, next) =>
  rateLimitByIdentity(c.env.RATE_LIMIT, 100, 60)(c, next)
);

// --- Route handlers ---

// Public API
app.get("/v1/posts", async (c) => {
  const posts = await c.env.DB.prepare(
    "SELECT id, title, slug, published_at FROM posts WHERE status = 'published' ORDER BY published_at DESC LIMIT 20"
  ).all();
  return c.json(posts.results);
});

// Admin API
app.get("/admin/posts", async (c) => {
  const posts = await c.env.DB.prepare(
    "SELECT * FROM posts ORDER BY created_at DESC"
  ).all();
  return c.json(posts.results);
});

app.post("/admin/posts", async (c) => {
  const email = c.get("userEmail");
  const body = await c.req.json();

  await c.env.DB.prepare(
    "INSERT INTO posts (title, slug, content, status, author_email) VALUES (?, ?, ?, ?, ?)"
  )
    .bind(body.title, body.slug, body.content, body.status || "draft", email)
    .run();

  // Emit event for the content pipeline
  await c.env.EVENTS.send({
    type: "post.created",
    slug: body.slug,
    author: email,
    timestamp: new Date().toISOString(),
  });

  return c.json({ success: true }, 201);
});

// Internal API β€” for other Workers
app.post("/internal/sync", async (c) => {
  // Process sync request from another Worker
  const body = await c.req.json();
  console.log(`Sync triggered: ${body.trigger}`);
  return c.json({ synced: true });
});

export default app;

When to use what

ScenarioRecommended StackWhy
Solo dev, one projectTailscale + CF Pages/WorkersSimplest setup, free tier covers everything
Solo dev, internal toolsTailscale ServeNo public exposure needed, instant setup
Small team, shared toolsTailscale + CF Access (email OTP)Identity-based, no IdP setup required
Team with Google WorkspaceCF Access (Google IdP) + TailscaleSSO integration, familiar login flow
Public product + internal adminCF Access (public) + Tailscale (internal)Split-horizon, each surface has appropriate auth
CI/CD pipeline accessCF Access Service TokensMachine-to-machine, no human interaction
Full zero-trustCF Tunnel + CF Access + Tailscale + tsidpMaximum security, no open ports, identity everywhere

Comparing security approaches

ApproachIdentity-BasedNo Open PortsEase of SetupOngoing MaintenanceCost (Small Team)
UFW + SSH KeysNoNoMediumHigh (key rotation, IP lists)Free
OpenVPNPartialNeeds VPN portHardHigh (server, certs, clients)$5-15/mo (server)
WireGuard (manual)NoNeeds WG portHardMedium (peer management)Free
TailscaleYesSSH: yes, serve: yesVery EasyVery LowFree (3 users)
Cloudflare AccessYesN/A (edge service)EasyLowFree (50 users)
CF Tunnel + AccessYesYesMediumLowFree (50 users)
AWS Security GroupsNoNoMediumMedium (rule management)Included with EC2
nginx basic authMinimalNoEasyMedium (htpasswd files)Free
CF WAF RulesNoN/A (edge rules)EasyLowFree (5 rules) / $20/mo
Full stack (this article)YesYesMediumVery LowFree

Tailscale vs. traditional VPNs

FeatureTailscaleWireGuard (manual)OpenVPN
ArchitectureMesh (peer-to-peer)Point-to-point or hubHub-and-spoke
ProtocolWireGuard (userspace)WireGuard (kernel)OpenVPN/OpenSSL
Setup time5 minutes30-60 minutes1-2 hours
Key managementAutomaticManualManual (PKI)
NAT traversalAutomaticManual port forwardingManual port forwarding
PerformanceVery goodBest (kernel)Good
Codebase size~100K lines~4K lines~100K lines
Identity integrationSSO/OIDC built-inNoneLDAP/RADIUS possible
ACLsBuilt-in, granulariptablesServer-side firewall
DNSMagicDNS automaticManualPush DNS possible
Audit loggingDashboard + APIManualLog files
Control planeTailscale-hostedSelf-hostedSelf-hosted
Free tier3 users, 100 devicesFully freeFully free
Max performanceUserspace overheadLine-rate (kernel module)CPU-bound (encryption)

Cloudflare Access vs. alternatives

FeatureCF AccessAWS CognitoAuth0Tailscale ACLs
Edge enforcementYes (CF edge)No (app-level)No (app-level)Yes (device-level)
Identity providersGoogle, GitHub, OIDC, SAML, email OTPCognito pools, OIDC, SAML50+ social + enterpriseSSO via OIDC
Service tokensYesAPI keysM2M tokensAPI keys
Browser-based authYes (login page)Yes (hosted UI)Yes (Universal Login)No (network-level)
JWT in headersAutomaticManualManualN/A
No code changesYes (edge intercept)Requires SDKRequires SDKNo code needed
Free tier50 users50K MAU25K MAU3 users (full features)
Paid pricing$7/user/moPay per auth$23/1K MAU$6/user/mo
WAF integrationNativeSeparate (AWS WAF)SeparateN/A
Tunnel integrationNative (cloudflared)N/AN/AN/A

Free tier coverage

ServiceFree TierWhat You GetEnough For
Tailscale PersonalFree forever3 users, 100 devices, MagicDNS, ACLsSolo dev, personal projects
Tailscale Personal Plus$5/month6 users, 100 devicesSmall team, side projects
CF Zero Trust FreeFree forever50 users, Access policies, GatewayMost small teams
CF Workers FreeFree100K requests/day, 10ms CPUDevelopment, low-traffic APIs
CF Pages FreeFree500 builds/month, unlimited bandwidthMost static/SSR sites
CF D1 FreeFree5M row reads/day, 100K writes/dayDevelopment, small apps
Cloudflare TunnelFreeUnlimited tunnelsEveryone
cloudflaredFreeOpen sourceEveryone

When you start paying

TriggerServiceCost
More than 3 Tailscale usersTailscale Starter$6/user/month
More than 50 CF Zero Trust usersCF ZT Pay-as-you-go$7/user/month
More than 100K Worker requests/dayWorkers Paid$5/month + $0.50/M requests
Need advanced WAF rulesCF Pro plan$20/month per domain
Need advanced CF Access features (SCIM, logpush)CF ZT EnterpriseCustom pricing
Need Tailscale SSO (non-Google/Microsoft/GitHub)Tailscale Starter$6/user/month

The solo developer sweet spot

For a solo developer running a Cloudflare Workers stack with a few internal tools:

Tailscale Personal:           $0/month
Cloudflare Free plan:         $0/month
Cloudflare Zero Trust Free:   $0/month
Cloudflare Workers Free:      $0/month
Cloudflare Pages Free:        $0/month
Cloudflare D1 Free:           $0/month
Cloudflare Tunnel:            $0/month
──────────────────────────────────────
Total:                        $0/month

What you get:
- Mesh VPN across all your devices
- Zero-trust access to internal tools
- DDoS protection on all public endpoints
- JWT-based identity verification
- No open ports on any machine
- TLS everywhere
- Rate limiting
- Audit logging

This is not a toy setup. This is production-grade security infrastructure for zero cost. The free tiers of both Tailscale and Cloudflare are genuinely generous.

The small team breakpoint

For a team of 10:

Tailscale Starter (10 users):   $60/month
Cloudflare Free plan:            $0/month
Cloudflare Zero Trust Free:      $0/month (still under 50 users)
Workers Paid (if needed):        $5/month
──────────────────────────────────────
Total:                          $65/month

Equivalent traditional setup:
VPN server (OpenVPN):           $20/month
SSL certificates (if not CF):   $10/month
Monitoring server:              $20/month
Extra VPS for bastion host:     $15/month
──────────────────────────────────────
Total:                          $65/month + your time managing it

The cost is similar, but the operational burden is dramatically different. With Tailscale + Cloudflare, you are managing policies, not infrastructure.


Don’tDo InsteadWhy
Leave *.workers.dev routes unprotectedValidate Cf-Access-Jwt-Assertion in your Worker even if Access is on your custom domainThe workers.dev URL bypasses your custom domain’s Access policy
Store service tokens in wrangler.jsoncUse wrangler secret put for all secretsConfig files end up in git. Secrets should never be in source control
Use Flexible SSL modeUse Full (strict)Flexible means CF→origin is unencrypted. MITM between CF and your origin is trivial
Create one service token for all machinesCreate separate tokens per service/pipelineRevocation is surgical. One compromised token doesn’t kill everything
Skip JWT validation because β€œAccess handles it”Always validate the JWT in your WorkerDefense in depth. Access is the lock. JWT validation is the deadbolt
Open port 22 to the internet for SSHUse Tailscale SSH (no port 22 needed)No port scanning target. Identity-based. Automatic key management
Use CF_Authorization cookie for validationUse Cf-Access-Jwt-Assertion headerCookie is not guaranteed in all contexts (API calls, service tokens)
Allow all traffic on your tailnetDefine ACL rules with least-privilegeDefault allow means a compromised device can reach everything
Run tailscale funnel when you mean tailscale serveKnow the difference: serve = private, funnel = publicFunnel exposes to the entire internet. Serve is tailnet-only
Hardcode Cloudflare IP ranges for origin protectionUse Cloudflare Tunnel insteadCF IP ranges change. Tunnel eliminates the need for IP-based origin protection
Use nginx basic auth for internal toolsUse CF Access or Tailscale ACLsBasic auth credentials are transmitted with every request. No SSO. No audit trail
Set long (or no) expiration on service tokensSet reasonable expiration (90-365 days) and rotateEternal tokens are a ticking time bomb. Rotation limits blast radius
Put database admin panels on public domainsExpose via tailscale serve onlyDatabase admin should never be on the public internet, even with auth
Trust CF-Connecting-IP without verifying request came through CFUse Cloudflare Tunnel or validate against CF IP rangesHeaders can be spoofed if requests don’t actually come through CF
Disable HSTS or use short max-ageSet max-age=31536000; includeSubDomains; preloadShort HSTS allows downgrade attacks during the gap
Use tailscale up without --ssh and forget to configure ACLsUse tailscale set --ssh and define SSH rules in ACL policyDefault SSH rules may be too permissive. Explicit rules prevent surprise access

Official Documentation

Tailscale Identity Provider

Comparisons and Analysis

Architecture and Design

Libraries and Tools

Blog Posts and Tutorials


Layer 6: CF Tunnel + Tailscale     ← No open ports. Origin invisible.
Layer 5: Tailscale as OIDC for CF  ← Seamless SSO for tailnet users.
Layer 4: Cloudflare Access         ← Identity at the edge. JWT in headers.
Layer 3: Cloudflare Reverse Proxy  ← DDoS, TLS, WAF, IP masking.
Layer 2: Tailscale Mesh VPN        ← Private network. Internal tools hidden.
Layer 1: Firewall + SSH Keys       ← Better than nothing. Not enough.

You don’t have to implement all six layers. Most solo developers will be well-served by Layer 2 (Tailscale for internal tools) + Layer 3 (Cloudflare for public endpoints) + Layer 4 (Cloudflare Access for admin panels). That’s a strong security posture for zero cost.

But if you’re running production services with real users and real data, layers 5 and 6 are worth the setup time. No open ports. Identity verified at every hop. Audit trails for every access. That’s not paranoia β€” that’s engineering.

The tools exist. They are free. The only cost is the afternoon it takes to set them up. Given what they protect, that’s the best ROI in your entire stack.


Edit page
Share this post on:

Previous Post
Progress Visibility
Next Post
Self-Healing Parsers