Your values Fill in your own to see them in every example
The domain in your people’s email addresses, as proved in the hub.
That doesn’t look right yet. For example: example.com
Saved in this browser only, for every page of these docs. Nothing you type leaves this page.
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:
- Person to Tailscale: signs in with their work email
- Tailscale to Your domain: GET /.well-known/webfinger?resource=acct:…
- Your domain to Tailscale: issuer: https://id.trustaige.com
- Tailscale to Trustaige: sends them to Trustaige
- Person and Trustaige: passkey
- 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"
}
]
}
| 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.
{
"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 reloadHost 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:
- In Apps, open the Tailscale app. On the Configuration tab, find WebFinger Discovery.
- Under Choose setup method, choose Self-hosted.
- 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
linkshasrelhttp://openid.net/specs/connect/1.0/issuerandhrefexactlyhttps://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.comRemove 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
- Custom OIDC providers: setting up Tailscale’s side, linked from the hub’s Tailscale app.