Skip to content
Trustaige Docs

Serve WebFinger discovery from your domain

Publish the WebFinger record that points apps like Tailscale from your email domain to Trustaige, on nginx, Cloudflare, Vercel, Netlify or Apache.

  • Every plan
  • Needs Tailscale
  • Needs Your domain’s web server
  • About 10 minutes
View as Markdown

You’re here to publish one small JSON file on your organization’s domain. It tells an app where your people sign in: when someone types their work email into the app, the app reads the file from the domain in that address, finds Trustaige there, and sends them to Trustaige to sign in with their passkey.

If an administrator sent you this link, they need nothing back from you but a working file, and you need nothing secret from them: only which domain to serve it on. The hub shows it as the organization’s Primary domain on the Domains page. The file is the same for every Trustaige organization apart from that domain, so everything you need is on this page.

The standard is WebFinger (RFC 7033), used as OpenID Connect Discovery describes.

Which apps need it

Today, Tailscale. An app needs WebFinger when it finds a person’s identity provider from their email domain rather than being told it. Tailscale works this way for custom OpenID Connect sign-in, so the hub’s Tailscale app asks you to set it up. For any other app, the hub doesn’t show the section and you don’t need the file.

How sign-in works once it’s served:

  1. Person to Tailscale: signs in with their work email
  2. Tailscale to Your domain: GET /.well-known/webfinger?resource=acct:…
  3. Your domain to Tailscale: issuer: https://id.trustaige.com
  4. Tailscale to Trustaige: sends them to Trustaige
  5. Person and Trustaige: passkey
  6. Trustaige to Tailscale: ID token

Before you begin

  • The app in the hub. An administrator who can manage apps adds Tailscale under Apps. The app’s setup includes a step called Set up WebFinger discovery; the setup itself lives on the app’s page afterwards, on the Configuration tab, in WebFinger Discovery.
  • A primary domain. The hub names and checks the address for the domain marked Primary on Domains. Without one, there is nothing to check.
  • Your web server. You serve the file at https://<your-domain>/.well-known/webfinger: the domain itself, over HTTPS with a valid certificate, on the standard port.

What to serve

The file, called a JSON Resource Descriptor:

{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}
Addresshttps://<your-domain>/.well-known/webfinger
MethodGET
Status200
Content-Typeapplication/jrd+json
QueryApps add resource (for example acct:alice@<your-domain>) and may add rel=http://openid.net/specs/connect/1.0/issuer. Answer every request with the same file, whatever the query says.

The part that matters is the link: its rel is the OpenID Connect issuer relation, and its href is https://id.trustaige.com exactly, with no trailing slash. The subject is the same fixed value whatever address is asked about.

Pick the section for the platform your domain’s website runs on. Each has your domain filled in from the values panel. In the hub, Self-hosted shows the address and the issuer, with a link to this page.

{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}

Host on nginx

Add this to the server block that serves https://<your-domain>:

# Add this to your nginx server block for <your-domain>
location = /.well-known/webfinger {
    default_type application/jrd+json;
    return 200 '{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}';
}

Then check the configuration and reload:

sudo nginx -t && sudo nginx -s reload

location = matches the path exactly; the query string doesn’t affect it, so every resource gets the same answer.

# Add this to your nginx server block for <your-domain>
location = /.well-known/webfinger {
    default_type application/jrd+json;
    return 200 '{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}';
}
sudo nginx -t && sudo nginx -s reload

Host on Cloudflare

A Cloudflare Worker answers the request at Cloudflare’s edge, in front of your website. Create a Worker with this code, in the dashboard or with wrangler:

// Cloudflare Worker — deploy via wrangler or the Cloudflare dashboard.
// This worker intercepts requests to /.well-known/webfinger and returns the OIDC issuer.
export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === "/.well-known/webfinger") {
      return new Response(JSON.stringify({
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}), {
        headers: {
          "Content-Type": "application/jrd+json",
          "Access-Control-Allow-Origin": "*",
        },
      });
    }
    return fetch(request);
  },
};

Give the Worker a route so it runs for your domain, for example <your-domain>/.well-known/webfinger*. A route only runs when the domain’s DNS record is proxied through Cloudflare. Any other request reaching the Worker passes through to your website unchanged.

// Cloudflare Worker — deploy via wrangler or the Cloudflare dashboard.
// This worker intercepts requests to /.well-known/webfinger and returns the OIDC issuer.
export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === "/.well-known/webfinger") {
      return new Response(JSON.stringify({
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}), {
        headers: {
          "Content-Type": "application/jrd+json",
          "Access-Control-Allow-Origin": "*",
        },
      });
    }
    return fetch(request);
  },
};

Host on Vercel

Two files in your project. Add the rewrite to vercel.json at the root:

{
  "rewrites": [
    {
      "source": "/.well-known/webfinger",
      "destination": "/api/webfinger"
    }
  ]
}

Then create the function it rewrites to:

export default function handler(req, res) {
  res.setHeader("Content-Type", "application/jrd+json");
  res.json({
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
});
}

Deploy, and make sure <your-domain> itself is assigned to this Vercel project.

{
  "rewrites": [
    {
      "source": "/.well-known/webfinger",
      "destination": "/api/webfinger"
    }
  ]
}
export default function handler(req, res) {
  res.setHeader("Content-Type", "application/jrd+json");
  res.json({
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
});
}

Host on Netlify

Create a file named webfinger, with no extension, in a .well-known folder in the directory Netlify publishes, with this content:

{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}

Then set its headers in netlify.toml:

[[headers]]
  for = "/.well-known/webfinger"
  [headers.values]
    Content-Type = "application/jrd+json"
    Access-Control-Allow-Origin = "*"
{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}
[[headers]]
  for = "/.well-known/webfinger"
  [headers.values]
    Content-Type = "application/jrd+json"
    Access-Control-Allow-Origin = "*"

Host on Apache

The rules, the file and the header go in two places. Save the record as webfinger.json in your site’s document root:

{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}

Then add the rewrite and the header to the .htaccess file in the same document root:

# Add to your .htaccess or Apache config for <your-domain>
RewriteEngine On
RewriteRule ^\.well-known/webfinger$ /webfinger.json [L]

# Add the correct Content-Type header:
<Files "webfinger.json">
    Header set Content-Type "application/jrd+json"
</Files>

The rules need mod_rewrite and mod_headers turned on, and .htaccess files allowed for the directory (AllowOverride).

{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}
# Add to your .htaccess or Apache config for <your-domain>
RewriteEngine On
RewriteRule ^\.well-known/webfinger$ /webfinger.json [L]

# Add the correct Content-Type header:
<Files "webfinger.json">
    Header set Content-Type "application/jrd+json"
</Files>

Check it is served

Ask for it the way the hub does:

curl -si "https://<your-domain>/.well-known/webfinger?resource=acct:test@<your-domain>&rel=http://openid.net/specs/connect/1.0/issuer"

You should see a 200, the content type, and the file:

HTTP/2 200
content-type: application/jrd+json

{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}

Your server may add other headers; the Cloudflare and Netlify configurations also send access-control-allow-origin: *. If you see HTML, a 404 or a redirect to a sign-in page, the request isn’t reaching the configuration; see Troubleshooting.

curl -si "https://<your-domain>/.well-known/webfinger?resource=acct:test@<your-domain>&rel=http://openid.net/specs/connect/1.0/issuer"
HTTP/2 200
content-type: application/jrd+json

{
  "subject": "acct:user@<your-domain>",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://id.trustaige.com"
    }
  ]
}

Verify in the hub

Once the curl check passes, tell your administrator, or check it yourself if you have access to the hub:

  1. In Apps, open the Tailscale app. On the Configuration tab, find WebFinger Discovery.
  2. Under Choose setup method, choose Self-hosted.
  3. Under Verify your endpoint, select Verify Setup.

The hub fetches the same address as the curl command above, waiting up to ten seconds, and passes when all of these hold:

  • the response is 200;
  • the body parses as a JSON Resource Descriptor;
  • one of its links has rel http://openid.net/specs/connect/1.0/issuer and href exactly https://id.trustaige.com.

It then shows Verification passed, with “WebFinger correctly points to https://id.trustaige.com as the OIDC issuer”, and a Verified badge on the section. Raw response shows the body it received. The hub doesn’t check the Content-Type header, so check that with curl; nor does it remember the result: the badge shows until you leave the page, and Re-verify checks again.

Then sign in to Tailscale with a work email on <your-domain>: Tailscale sends you to Trustaige, and you sign in with your passkey.

Without your own domain

If the website on your domain can’t serve the file, Managed fallback under Choose setup method uses the WebFinger endpoint Trustaige runs at id.trustaige.com, which answers for any address on it. Nothing goes on your domain. Instead, each person enters an address on id.trustaige.com in the app, rather than their work email:

<identity-handle>@id.trustaige.com

The hub tells people to find their identity handle on their Identity page, from the profile menu. Trustaige then puts that address in the token’s email claim instead of the person’s work email, so the app sees id.trustaige.com as their domain. Self-hosted is the recommended method for that reason.

<identity-handle>@id.trustaige.com

Remove it

Removing the file stops apps finding Trustaige from your domain: delete the location, Worker route, rewrite or file you added. Before you do, remove Trustaige as the identity provider in Tailscale’s admin console, under Settings then User management, as the hub’s disconnect steps say; otherwise Tailscale keeps sending people to Trustaige and they can’t sign in.

Troubleshooting

The app's page has no WebFinger Discovery section

The section appears only on an OpenID Connect app added from the Tailscale template, on its Configuration tab. If the hub also showed “Failed to generate WebFinger config” with “organization does not have a domain configured”, the organization has no primary domain: add one on Domains, prove it, and make it Primary.

“Failed to reach https://…/.well-known/webfinger…”

The hub couldn’t connect. The detail line says why: the domain doesn’t resolve, nothing answers on port 443, the certificate isn’t valid for the domain itself, or the server took longer than ten seconds. Run the curl check from outside your network.

“WebFinger endpoint returned HTTP 404”

The request reached a server that doesn’t have the configuration. On nginx, the location is in a different server block from the one serving <your-domain>, or nginx wasn’t reloaded. On Cloudflare, the Worker has no route for the domain, or the DNS record isn’t proxied. On Vercel, the domain belongs to another project, or the function isn’t at api/webfinger.js. On Netlify, the .well-known folder isn’t inside the publish directory. On Apache, .htaccess isn’t read (AllowOverride), mod_rewrite is off, or webfinger.json isn’t in the document root. Any other status, such as 301 to a sign-in page or 403, means something in front of the site is answering first.

“Response is not valid JSON Resource Descriptor”

The server answered 200 with something that isn’t the file: usually your website’s own HTML page, because a catch-all route answered before the WebFinger configuration did. Open Raw response to see what came back, and make the WebFinger rule take precedence for /.well-known/webfinger.

“WebFinger response does not contain a link with rel=… pointing to https://id.trustaige.com”

The file was served but its link doesn’t match. Check that rel is exactly http://openid.net/specs/connect/1.0/issuer and href is exactly https://id.trustaige.com: no trailing slash, https, nothing edited. Copy the file from What to serve again.

The hub says Verification passed, but Tailscale doesn't find Trustaige

Check the content-type with curl: the hub doesn’t, and it must be application/jrd+json. Check also that the person’s email is on the primary domain; the hub builds and checks the file for that domain only.

Tailscale’s documentation