DEV Community

Cover image for Tailscale Custom OIDC with Cloudflare Zero Trust and a Cloudflare Worker
de4ps
de4ps

Posted on

Tailscale Custom OIDC with Cloudflare Zero Trust and a Cloudflare Worker

Tailscale supports custom OIDC providers for authentication. Instead of building a full OIDC identity provider from scratch, you can use Cloudflare Zero Trust as the IdP and a tiny Cloudflare Worker as the glue. The worker serves a single endpoint — WebFinger — that lets Tailscale discover the OIDC issuer on your domain.

The total amount of code: ~30 lines of TypeScript.

Background

When you sign up for Tailscale with a custom domain (say user@example.com), Tailscale needs to find the OIDC provider responsible for that domain. It does this via WebFinger — an HTTP-based protocol for discovering information about resources.

Tailscale sends a GET request to:

https://example.com/.well-known/webfinger?resource=acct:user@example.com&rel=http://openid.net/specs/connect/1.0/issuer
Enter fullscreen mode Exit fullscreen mode

The response must be a JSON Resource Descriptor (JRD) pointing to the OIDC issuer:

{
  "subject": "acct:user@example.com",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://your-issuer-url"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Tailscale then fetches /.well-known/openid-configuration from that issuer and proceeds with the standard OIDC Authorization Code flow.

The idea

Cloudflare Zero Trust can act as a full OIDC identity provider when you create a SaaS application. It handles everything: authorization, token exchange, user info, JWKS. Users authenticate via Cloudflare Access (one-time PIN sent to email, or any other identity provider you configure in Zero Trust).

The only piece missing is the WebFinger endpoint on your domain. A Cloudflare Worker fills that gap.

The full flow

1. User enters user@example.com in Tailscale login
2. Tailscale → GET https://example.com/.well-known/webfinger
3. Cloudflare Worker responds with the CF Zero Trust issuer URL
4. Tailscale → GET <issuer>/.well-known/openid-configuration
5. Tailscale redirects user to Cloudflare Access authorization endpoint
6. User authenticates (email OTP, or configured IdP)
7. Cloudflare redirects to https://login.tailscale.com/a/oauth_response
8. Tailscale exchanges the authorization code for tokens
9. User is authenticated in the tailnet
Enter fullscreen mode Exit fullscreen mode

Step 1: Cloudflare Zero Trust — SaaS Application

In the Cloudflare Zero Trust dashboard:

  1. Go to Access → Applications → Add an application
  2. Choose SaaS
  3. Set the protocol to OIDC
  4. Set the redirect URL to https://login.tailscale.com/a/oauth_response
  5. Enable scopes: openid, email, profile
  6. Create an Access Policy to control who can authenticate (e.g., only specific email addresses or an email domain)
  7. Save the application and copy the Client ID, Client Secret, and Issuer URL

The issuer URL will look something like:

https://<team-name>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client-id>
Enter fullscreen mode Exit fullscreen mode

Step 2: Cloudflare Worker — WebFinger endpoint

Initialize a new project:

mkdir example-com && cd example-com
npm init -y
npm install hono
npm install -D wrangler typescript @cloudflare/workers-types
Enter fullscreen mode Exit fullscreen mode

wrangler.toml

name = "example-com"
main = "src/index.ts"
compatibility_date = "2025-01-01"

# OIDC_ISSUER stored as a secret:
# npx wrangler secret put OIDC_ISSUER
Enter fullscreen mode Exit fullscreen mode

src/index.ts

The entire worker:

import { Hono } from "hono";

type Env = {
  OIDC_ISSUER: string;
};

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

app.get("/.well-known/webfinger", (c) => {
  const resource = c.req.query("resource");
  if (!resource) {
    return c.json({ error: "missing resource parameter" }, 400);
  }

  // Validate the resource is an acct: URI for your domain
  const match = resource.match(/^acct:(.+)@(.+)$/);
  if (!match || match[2] !== "example.com") {
    return c.json({ error: "unknown resource" }, 404);
  }

  // Optional: validate the rel parameter
  const rel = c.req.query("rel");
  if (rel && rel !== "http://openid.net/specs/connect/1.0/issuer") {
    return c.json({ error: "unsupported rel" }, 404);
  }

  return c.json(
    {
      subject: resource,
      links: [
        {
          rel: "http://openid.net/specs/connect/1.0/issuer",
          href: c.env.OIDC_ISSUER,
        },
      ],
    },
    200,
    { "Content-Type": "application/jrd+json" },
  );
});

export default app;
Enter fullscreen mode Exit fullscreen mode

The worker validates that the resource parameter is an acct: URI for your domain, and returns a JRD response pointing to the OIDC issuer URL stored in the OIDC_ISSUER secret.

Deploy

Connect the repository to Cloudflare for automatic deployments, or deploy manually:

npx wrangler deploy
Enter fullscreen mode Exit fullscreen mode

Set the issuer secret (the issuer URL from Step 1):

npx wrangler secret put OIDC_ISSUER
Enter fullscreen mode Exit fullscreen mode

Make sure the worker is routed to your domain. You can either set up a custom domain in the Cloudflare dashboard or add a route in wrangler.toml:

routes = [
  { pattern = "example.com/*", zone_name = "example.com" }
]
Enter fullscreen mode Exit fullscreen mode

Verify

curl "https://example.com/.well-known/webfinger?resource=acct:user@example.com&rel=http%3A%2F%2Fopenid.net%2Fspecs%2Fconnect%2F1.0%2Fissuer"
Enter fullscreen mode Exit fullscreen mode

Should return:

{
  "subject": "acct:user@example.com",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://<team-name>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client-id>"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Tailscale — Custom OIDC

  1. Go to the Tailscale admin console
  2. Navigate to Identity providers → Custom OIDC (or sign up with OIDC if creating a new tailnet)
  3. Enter your email address (e.g., user@example.com)
  4. Issuer URL: the issuer URL from Step 1
  5. Client ID: from Step 1
  6. Client Secret: from Step 1
  7. Prompts: select login

That's it

The result: Tailscale authenticates users on your custom domain through Cloudflare Zero Trust, with a ~30-line Cloudflare Worker as the bridge. No databases, no key management, no token handling — Cloudflare does all the heavy OIDC lifting.

To add or remove users, manage them in the Cloudflare Zero Trust Access Policy. The worker itself never needs to change.

Top comments (0)