# 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.

Source: https://docs.trustaige.com/manage/webfinger

Replace these placeholders with your organization's own values:

- `<your-domain>`: Your domain, for example `example.com`. The domain in your people’s email addresses, as proved in the hub.

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](https://datatracker.ietf.org/doc/html/rfc7033)),
used as [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery)
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 → Tailscale: signs in with their work email
2. Tailscale → Your domain: GET /.well-known/webfinger?resource=acct:…
3. Your domain → Tailscale: issuer: https://id.trustaige.com
4. Tailscale → Trustaige: sends them to Trustaige
5. Person ↔ Trustaige: passkey
6. Trustaige → 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:

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

| | |
|---|---|
| Address | `https://<your-domain>/.well-known/webfinger` |
| Method | `GET` |
| Status | `200` |
| `Content-Type` | `application/jrd+json` |
| Query | Apps 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.

## Host on nginx

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

```nginx title="nginx.conf"
# 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:

```bash
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.

## 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`:

```js title="worker.js"
// 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.

## Host on Vercel

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

```json title="vercel.json"
{
  "rewrites": [
    {
      "source": "/.well-known/webfinger",
      "destination": "/api/webfinger"
    }
  ]
}
```

Then create the function it rewrites to:

```js title="api/webfinger.js"
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.

## Host on Netlify

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

```json title=".well-known/webfinger"
{
  "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`:

```toml title="netlify.toml"
[[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:

```json title="webfinger.json"
{
  "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:

```apache title=".htaccess"
# 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`).

## Check it is served

Ask for it the way the hub does:

```bash
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 {1-2}
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](#troubleshooting).

## 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:

```text
<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.

## 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](#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

- [Custom OIDC providers](https://tailscale.com/kb/1240/sso-custom-oidc):
  setting up Tailscale's side, linked from the hub's Tailscale app.
