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:
- Why IP-based security is fundamentally broken and what replaced it
- How to use Tailscale to create a private mesh network across your machines
- How to put Cloudflare in front of everything as a reverse proxy
- How Cloudflare Access intercepts requests and enforces identity-based policies
- How to wire Tailscale as an OIDC identity provider for Cloudflare Access
- How Cloudflare Tunnel eliminates open ports entirely
- How to validate JWTs from Cloudflare Access in your Cloudflare Workers
- Cost analysis: whatβs free, what costs money, and where the breakpoints are
- A complete architecture for a Cloudflare Workers stack with public and private surfaces
- The Problem: Your Server Is Naked
- Layer 1: Basic Security β Firewalls, SSH Keys, IP Allowlists
- Layer 2: Tailscale Mesh VPN β Your Private Network
- Layer 3: Cloudflare as Reverse Proxy β Shield Your Origin
- Layer 4: Cloudflare Access β Identity at the Edge
- Layer 5: Tailscale as OIDC Provider for Cloudflare Access
- Layer 6: Cloudflare Tunnel + Tailscale β No Open Ports
- Pattern: JWT Validation in Cloudflare Workers
- Pattern: Service Token Authentication for Machine-to-Machine
- Pattern: Multi-Tier Auth for Workers
- Pattern: Security Headers Middleware
- Pattern: Tailscale SSH for Server Access
- Pattern: Split-Horizon Architecture
- Small Examples
- Real-World Architecture
- Comparison Matrix
- Cost Analysis
- Anti-Patterns
- References
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
| Problem | Impact |
|---|---|
| Your IP changes (coffee shop, travel, ISP reassignment) | You lock yourself out of your own server |
| Team members have different IPs | You maintain a growing allowlist thatβs always stale |
| VPN exit nodes change IPs | Your βstaticβ IP is not static |
| No identity β only network location | Anyone at that IP gets access, not just you |
| Manual management | Every new server needs the same rules applied |
| No audit trail | You donβt know who connected, just which IP |
| IPv6 adds complexity | Dual-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:
src: Who is making the connection (users, groups, tags)dst: What they can connect to (devices and ports)autogroup:member: All members of your tailnetautogroup:admin: All admins of your tailnettag:xxx: Devices tagged withxxx- Rules are deny-by-default: If no rule matches, the connection is blocked
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:
- DDoS protection (automatic, always on)
- TLS termination (free Universal SSL certificates)
- WAF rules (basic rules on free plan, advanced on paid)
- Caching (for static assets)
- HTTP/2 and HTTP/3 support
- IP masking (your origin IP is hidden from
digandnslookup)
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:
- Browser to Cloudflare: encrypted (Cloudflareβs certificate)
- 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:
- Cloudflare checks for an existing session (JWT in
CF_Authorizationcookie) - If no session: redirect to the identity provider login page
- User authenticates with their IdP (Google, GitHub, email OTP, etc.)
- Cloudflare issues a JWT and sets it as a cookie
- The request proceeds to your app with the JWT in the
Cf-Access-Jwt-Assertionheader - 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 Type | Example | Use Case |
|---|---|---|
| Emails | alice@company.com | Specific individuals |
| Emails ending in | @company.com | Entire organization |
| Identity provider groups | engineering | Team-based access |
| IP ranges | 203.0.113.0/24 | Office networks |
| Country | US, CA | Geographic 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:
- You access an internal tool
- Cloudflare Access shows a login page
- You authenticate via Google/GitHub/email
- You get access
With Tailscale as IdP:
- You access an internal tool
- Cloudflare Access checks your Tailscale identity (already authenticated)
- 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:
- From the internet: through Cloudflare Tunnel (with Access auth)
- 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-Assertionheader, not theCF_Authorizationcookie. 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 Pattern | Auth Method | Who Uses It |
|---|---|---|
/health | None | Load balancers, uptime monitors |
/internal/* | Service key | Other Workers, cron jobs |
/admin/* | Access JWT | Human users via browser |
/v1/* | Either | Both 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
| Scenario | Recommended Stack | Why |
|---|---|---|
| Solo dev, one project | Tailscale + CF Pages/Workers | Simplest setup, free tier covers everything |
| Solo dev, internal tools | Tailscale Serve | No public exposure needed, instant setup |
| Small team, shared tools | Tailscale + CF Access (email OTP) | Identity-based, no IdP setup required |
| Team with Google Workspace | CF Access (Google IdP) + Tailscale | SSO integration, familiar login flow |
| Public product + internal admin | CF Access (public) + Tailscale (internal) | Split-horizon, each surface has appropriate auth |
| CI/CD pipeline access | CF Access Service Tokens | Machine-to-machine, no human interaction |
| Full zero-trust | CF Tunnel + CF Access + Tailscale + tsidp | Maximum security, no open ports, identity everywhere |
Comparing security approaches
| Approach | Identity-Based | No Open Ports | Ease of Setup | Ongoing Maintenance | Cost (Small Team) |
|---|---|---|---|---|---|
| UFW + SSH Keys | No | No | Medium | High (key rotation, IP lists) | Free |
| OpenVPN | Partial | Needs VPN port | Hard | High (server, certs, clients) | $5-15/mo (server) |
| WireGuard (manual) | No | Needs WG port | Hard | Medium (peer management) | Free |
| Tailscale | Yes | SSH: yes, serve: yes | Very Easy | Very Low | Free (3 users) |
| Cloudflare Access | Yes | N/A (edge service) | Easy | Low | Free (50 users) |
| CF Tunnel + Access | Yes | Yes | Medium | Low | Free (50 users) |
| AWS Security Groups | No | No | Medium | Medium (rule management) | Included with EC2 |
| nginx basic auth | Minimal | No | Easy | Medium (htpasswd files) | Free |
| CF WAF Rules | No | N/A (edge rules) | Easy | Low | Free (5 rules) / $20/mo |
| Full stack (this article) | Yes | Yes | Medium | Very Low | Free |
Tailscale vs. traditional VPNs
| Feature | Tailscale | WireGuard (manual) | OpenVPN |
|---|---|---|---|
| Architecture | Mesh (peer-to-peer) | Point-to-point or hub | Hub-and-spoke |
| Protocol | WireGuard (userspace) | WireGuard (kernel) | OpenVPN/OpenSSL |
| Setup time | 5 minutes | 30-60 minutes | 1-2 hours |
| Key management | Automatic | Manual | Manual (PKI) |
| NAT traversal | Automatic | Manual port forwarding | Manual port forwarding |
| Performance | Very good | Best (kernel) | Good |
| Codebase size | ~100K lines | ~4K lines | ~100K lines |
| Identity integration | SSO/OIDC built-in | None | LDAP/RADIUS possible |
| ACLs | Built-in, granular | iptables | Server-side firewall |
| DNS | MagicDNS automatic | Manual | Push DNS possible |
| Audit logging | Dashboard + API | Manual | Log files |
| Control plane | Tailscale-hosted | Self-hosted | Self-hosted |
| Free tier | 3 users, 100 devices | Fully free | Fully free |
| Max performance | Userspace overhead | Line-rate (kernel module) | CPU-bound (encryption) |
Cloudflare Access vs. alternatives
| Feature | CF Access | AWS Cognito | Auth0 | Tailscale ACLs |
|---|---|---|---|---|
| Edge enforcement | Yes (CF edge) | No (app-level) | No (app-level) | Yes (device-level) |
| Identity providers | Google, GitHub, OIDC, SAML, email OTP | Cognito pools, OIDC, SAML | 50+ social + enterprise | SSO via OIDC |
| Service tokens | Yes | API keys | M2M tokens | API keys |
| Browser-based auth | Yes (login page) | Yes (hosted UI) | Yes (Universal Login) | No (network-level) |
| JWT in headers | Automatic | Manual | Manual | N/A |
| No code changes | Yes (edge intercept) | Requires SDK | Requires SDK | No code needed |
| Free tier | 50 users | 50K MAU | 25K MAU | 3 users (full features) |
| Paid pricing | $7/user/mo | Pay per auth | $23/1K MAU | $6/user/mo |
| WAF integration | Native | Separate (AWS WAF) | Separate | N/A |
| Tunnel integration | Native (cloudflared) | N/A | N/A | N/A |
Free tier coverage
| Service | Free Tier | What You Get | Enough For |
|---|---|---|---|
| Tailscale Personal | Free forever | 3 users, 100 devices, MagicDNS, ACLs | Solo dev, personal projects |
| Tailscale Personal Plus | $5/month | 6 users, 100 devices | Small team, side projects |
| CF Zero Trust Free | Free forever | 50 users, Access policies, Gateway | Most small teams |
| CF Workers Free | Free | 100K requests/day, 10ms CPU | Development, low-traffic APIs |
| CF Pages Free | Free | 500 builds/month, unlimited bandwidth | Most static/SSR sites |
| CF D1 Free | Free | 5M row reads/day, 100K writes/day | Development, small apps |
| Cloudflare Tunnel | Free | Unlimited tunnels | Everyone |
| cloudflared | Free | Open source | Everyone |
When you start paying
| Trigger | Service | Cost |
|---|---|---|
| More than 3 Tailscale users | Tailscale Starter | $6/user/month |
| More than 50 CF Zero Trust users | CF ZT Pay-as-you-go | $7/user/month |
| More than 100K Worker requests/day | Workers Paid | $5/month + $0.50/M requests |
| Need advanced WAF rules | CF Pro plan | $20/month per domain |
| Need advanced CF Access features (SCIM, logpush) | CF ZT Enterprise | Custom 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βt | Do Instead | Why |
|---|---|---|
Leave *.workers.dev routes unprotected | Validate Cf-Access-Jwt-Assertion in your Worker even if Access is on your custom domain | The workers.dev URL bypasses your custom domainβs Access policy |
Store service tokens in wrangler.jsonc | Use wrangler secret put for all secrets | Config files end up in git. Secrets should never be in source control |
Use Flexible SSL mode | Use Full (strict) | Flexible means CFβorigin is unencrypted. MITM between CF and your origin is trivial |
| Create one service token for all machines | Create separate tokens per service/pipeline | Revocation is surgical. One compromised token doesnβt kill everything |
| Skip JWT validation because βAccess handles itβ | Always validate the JWT in your Worker | Defense in depth. Access is the lock. JWT validation is the deadbolt |
| Open port 22 to the internet for SSH | Use Tailscale SSH (no port 22 needed) | No port scanning target. Identity-based. Automatic key management |
Use CF_Authorization cookie for validation | Use Cf-Access-Jwt-Assertion header | Cookie is not guaranteed in all contexts (API calls, service tokens) |
| Allow all traffic on your tailnet | Define ACL rules with least-privilege | Default allow means a compromised device can reach everything |
Run tailscale funnel when you mean tailscale serve | Know the difference: serve = private, funnel = public | Funnel exposes to the entire internet. Serve is tailnet-only |
| Hardcode Cloudflare IP ranges for origin protection | Use Cloudflare Tunnel instead | CF IP ranges change. Tunnel eliminates the need for IP-based origin protection |
| Use nginx basic auth for internal tools | Use CF Access or Tailscale ACLs | Basic auth credentials are transmitted with every request. No SSO. No audit trail |
| Set long (or no) expiration on service tokens | Set reasonable expiration (90-365 days) and rotate | Eternal tokens are a ticking time bomb. Rotation limits blast radius |
| Put database admin panels on public domains | Expose via tailscale serve only | Database admin should never be on the public internet, even with auth |
Trust CF-Connecting-IP without verifying request came through CF | Use Cloudflare Tunnel or validate against CF IP ranges | Headers can be spoofed if requests donβt actually come through CF |
| Disable HSTS or use short max-age | Set max-age=31536000; includeSubDomains; preload | Short HSTS allows downgrade attacks during the gap |
Use tailscale up without --ssh and forget to configure ACLs | Use tailscale set --ssh and define SSH rules in ACL policy | Default SSH rules may be too permissive. Explicit rules prevent surprise access |
Official Documentation
- Tailscale Documentation β Complete docs covering installation, features, and configuration
- Tailscale CLI Reference:
serveβ All flags and usage patterns for exposing services within your tailnet - Tailscale CLI Reference:
funnelβ All flags and usage patterns for exposing services to the public internet - Tailscale ACL Policy Reference β Managing permissions using access control lists
- Tailscale ACL Policy Examples β Example ACL configurations for common scenarios
- Tailscale Grants β Next-generation access control syntax (replacing ACLs)
- Tailscale SSH β SSH without managing keys, identity-based authentication
- Tailscale Serve β Feature overview for sharing services within your tailnet
- Tailscale Funnel β Feature overview for sharing services with the public internet
- Tailscale Pricing β Free, Personal Plus, Starter, and Premium plans
- Tailscale Free Plans β Details on free tier limits and discounts
- Cloudflare One (Zero Trust) Documentation β Complete docs for Cloudflareβs zero-trust platform
- Cloudflare Access Applications β Setting up Access-protected applications
- Cloudflare Access Policies β Configuring who can access what
- Cloudflare Access JWT Validation β Validating JWTs from Access in your application
- Cloudflare Access Service Tokens β Machine-to-machine authentication
- Cloudflare Tunnel Configuration β Tunnel ingress rules and local management
- Cloudflare Pages Access Plugin β Middleware for validating Access JWTs in Pages Functions
- Cloudflare Zero Trust Pricing β Free, pay-as-you-go, and enterprise plans
- Configure an Identity Provider for CF Access β Step-by-step IdP configuration
- Create an Access Application β Step-by-step application setup
- Cloudflare Workers with Access Headers β Tutorial on using Workers to create custom headers for Access-protected origins
Tailscale Identity Provider
- tsidp β Tailscale OIDC/OAuth Identity Provider β GitHub repo for the lightweight IdP server that runs on your tailnet
- Building tsidp β How Tailscale built the lightweight identity provider
- Custom OIDC Providers for Tailscale β Using custom OIDC providers with Tailscale
- Tailscale Workload Identity Federation β Machine identity and OIDC token exchange
Comparisons and Analysis
- Tailscale vs. Cloudflare Access β Tailscaleβs official comparison
- OpenVPN vs. Tailscale β Comparing traditional VPN with mesh networking
- WireGuard vs. Tailscale β Understanding the relationship between WireGuard and Tailscale
- OpenVPN vs. WireGuard vs. Tailscale β Three-way comparison of VPN solutions
- Cloudflare Tunnel vs. ngrok vs. Tailscale β Tunneling solutions compared
- From Cloudflare Zero-trust to Tailscale β Real-world migration experience
- Zero Trust SSH on VPS (2025) β Comparing Teleport, Tailscale SSH, and CF Access for VPS access
- Tailscale vs Cloudflare Tunnel: Network Security Compared β Side-by-side comparison for network security
Architecture and Design
- Zero Trust for Startups β Cloudflare Reference Architecture β Cloudflareβs reference architecture for startup zero-trust
- Designing ZTNA Access Policies β Best practices for designing access policies
- How to Implement Zero Trust Security β Cloudflareβs step-by-step zero-trust guide
- Reintroducing Serve and Funnel β Tailscaleβs blog post on the redesigned serve/funnel commands
- Many Services, One cloudflared β Running multiple services through a single tunnel
Libraries and Tools
- jose β JavaScript OIDC/JWT library β The JWT library recommended by Cloudflare for Workers
- cloudflare-worker-jwt β Lightweight JWT implementation with zero dependencies for Workers
- Hono β Web framework for Cloudflare Workers (used in all examples)
- WireGuard β The VPN protocol that Tailscale is built on
- cloudflared β The Cloudflare Tunnel client
Blog Posts and Tutorials
- Give Your Automated Services Credentials with Access Service Tokens β Cloudflareβs blog post on service tokens
- OAuth 2.0 Auth Server on Workers β Building an OAuth server on Cloudflare Workers
- Tailscale SSH: Simplify and Secure SSH Connections β Why Tailscale SSH exists and how it works
- How Tailscaleβs Free Plan Stays Free β Tailscaleβs business model and free tier philosophy
- Tailscale Funnel: Share with the Public Internet β Introducing Funnel for public exposure
- Expose Local Services with Tailscale or Cloudflare β Practical comparison of exposure methods
- Simple JWT Authentication with Cloudflare Workers β Hands-on JWT implementation tutorial
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.