A practical guide to WebFinger

· PM-mirror


Type @pablo@pablomurad.com into the search box of any Mastodon server and you'll find an account. That account doesn't live on this blog. It lives on murad.social, a GoToSocial server, under the handle @pablo@murad.social. What connects the two is a single HTTPS request to this site, at an address that starts with /.well-known/webfinger, and a few lines of JSON that come back.

That protocol is WebFinger. It's small, it has been around since 2013, and a lot of the fediverse depends on it. This post explains what it is, how a request and an answer look, who uses it, and how to set it up on your own domain, from a single static file to a short script that follows the spec properly.

Where the name comes from #

Long before the web, Unix systems had finger. You typed finger user@host and the other machine told you who that person was, when they last logged in, and whatever they wrote in their .plan file. The first spec, RFC 742, is from 1977. (Finger.Farm, from Neat Finds #04, still hosts finger accounts today.)

WebFinger takes the same idea to HTTPS. You ask a domain about an identifier, usually something that looks like an email address, and the domain answers with a JSON document full of links: where the profile is, where the avatar is, which server handles the account. It's RFC 7033, published in September 2013 as a Proposed Standard. The project's home is webfinger.net, with the spec, a list of code and the link relations it registers.

One request, one answer #

Every WebFinger request goes to the same path on the domain, /.well-known/webfinger, with a resource parameter saying who or what you're asking about. You can try it with curl:

curl -s "https://pablomurad.com/.well-known/webfinger?resource=acct:pablo@pablomurad.com"

This is what comes back (formatted here, one item per line):

{
"subject": "acct:pablo@murad.social",
"aliases": [
"https://murad.social/users/pablo",
"https://murad.social/@pablo"
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://murad.social/@pablo"
},
{
"rel": "self",
"type": "application/activity+json",
"href": "https://murad.social/users/pablo"
}
]
}

A few things to notice in the request. acct: is a URI scheme made for accounts, defined later in RFC 7565. It looks like an email address, but nothing says there has to be a mailbox behind it. The resource can also be any other URI, like https://pablomurad.com/ or a mailto: address, as long as the server knows what to do with it.

The answer is a JRD, short for JSON Resource Descriptor, and it's sent with the content type application/jrd+json. It has four possible members:

Look at the subject in the answer above: the question was about acct:pablo@pablomurad.com and the answer is about acct:pablo@murad.social. That's allowed, and it's the reason the trick works. Mastodon's docs explain what happens next: when the subject differs from what was asked, Mastodon makes a second WebFinger request to the domain in the subject, checks that it points to the same account, and goes with that.

The rules in the spec #

RFC 7033 is short, and the rules a server has to follow fit in a list:

Who asks these questions #

The fediverse is the biggest user. When you search for @someone@example.com on Mastodon, GoToSocial or most other servers, the first thing they do is ask example.com over WebFinger. Mastodon's documentation lists the links it cares about:

OpenID Connect uses WebFinger to find the identity provider for an address. The link has rel set to http://openid.net/specs/connect/1.0/issuer and its href is the issuer URL. The RFC's own first example is exactly this, and services use it in the wild: Tailscale, when you sign up with a custom OIDC provider, asks the domain of your email address over WebFinger and expects that issuer link back, matching the issuer in your provider's /.well-known/openid-configuration.

webfinger.net also keeps a small list of its own link relations. There are two: http://webfinger.net/rel/avatar for a picture and http://webfinger.net/rel/profile-page for the main page about the account.

Setting it up #

There are three ways to do this, from quickest to most correct. Pick the one that fits how many people live on your domain.

One note on the code below: it's written flat, with no indentation, because this blog's editor collapses repeated spaces. nginx, Caddy and PHP don't care.

One static file #

This is the fastest way to make you@yourdomain find your fediverse account. Start by saving the answer your own server already gives (swap in your handle and instance):

curl -s "https://murad.social/.well-known/webfinger?resource=acct:pablo@murad.social" > webfinger.json

Put webfinger.json somewhere on your web server and tell the server to answer the WebFinger path with it. On nginx, inside the server block of your domain:

location = /.well-known/webfinger {
types { }
default_type application/jrd+json;
add_header Access-Control-Allow-Origin "*" always;
alias /var/www/example.com/webfinger.json;
}

The empty types { } is there on purpose. Without it, nginx looks at the .json extension and sends application/json, which isn't the content type the spec asks for.

On Caddy, inside your site block:

handle /.well-known/webfinger {
header Content-Type application/jrd+json
header Access-Control-Allow-Origin *
rewrite * /webfinger.json
file_server
}

This assumes your site block already has a root pointing to the folder where the file lives.

Reload the server and test it. Then go to any fediverse account and search for @you@yourdomain. Your profile should show up.

There are trade-offs, and they all come from the file not reading the question:

pablomurad.com answers exactly like this today: any name at this domain leads to the same account. For a domain with one person behind it, that's usually fine.

Several accounts with an nginx map #

If a few people share a domain, nginx can still do it without any code. A map picks a file based on the resource parameter. The map goes in the http block, outside any server:

map $arg_resource $webfinger_file {
default "";
"acct:pablo@example.com" pablo.json;
"acct%3Apablo%40example.com" pablo.json;
"acct:ana@example.com" ana.json;
"acct%3Aana%40example.com" ana.json;
}

And the location goes inside your server:

location = /.well-known/webfinger {
if ($arg_resource = "") { return 400; }
if ($webfinger_file = "") { return 404; }
types { }
default_type application/jrd+json;
add_header Access-Control-Allow-Origin "*" always;
root /var/www/webfinger;
try_files /$webfinger_file =404;
}

Each account gets its own file in /var/www/webfinger. Now a missing resource gets a 400, an unknown name gets a 404, and each person gets their own answer.

Each account appears twice in the map because $arg_resource is the raw query string. Some clients send acct:pablo@example.com and others encode it as acct%3Apablo%40example.com, and nginx doesn't decode it for you. The map is also case-sensitive. It's fine for a handful of accounts. Past that, or if you want the rel filter, it's time for a script.

A short PHP script that follows the spec #

PHP runs almost everywhere, from a VPS to cheap shared hosting, and it's enough for this. The script reads a list of accounts, each pointing to a JSON file, and handles everything the spec asks for: the 400, the 404, CORS, the right content type and the rel filter.

Save it as webfinger.php at the root of your site:

\_\_DIR\_\_ . '/webfinger/pablo.json', 'https://example.com/' => \_\_DIR\_\_ . '/webfinger/pablo.json', \]; // Any web page may read this, so CORS is open (RFC 7033 asks for it). header('Access-Control-Allow-Origin: \*'); // Read the query by hand: PHP keeps only the last "rel" if there are several. $resource = ''; $rels = \[\]; foreach (explode('&', $\_SERVER\['QUERY\_STRING'\] ?? '') as $pair) { \[$key, $value\] = array\_pad(explode('=', $pair, 2), 2, ''); $value = urldecode($value); if ($key === 'resource') { $resource = $value; } if ($key === 'rel') { $rels\[\] = $value; } } // No resource at all: a bad request. if ($resource === '') { http\_response\_code(400); exit("Missing resource parameter\\n"); } // Nobody by that name here. if (!isset($accounts\[$resource\])) { http\_response\_code(404); exit("Not found\\n"); } $jrd = json\_decode(file\_get\_contents($accounts\[$resource\]), true); // If the client asked for specific rels, return only those links. if ($rels && isset($jrd\['links'\])) { $jrd\['links'\] = array\_values(array\_filter( $jrd\['links'\], fn($link) => in\_array($link\['rel'\] ?? '', $rels, true) )); } header('Content-Type: application/jrd+json'); echo json\_encode($jrd, JSON\_UNESCAPED\_SLASHES | JSON\_PRETTY\_PRINT), "\\n"; The JSON files go in a `webfinger/` folder next to it, one per person, written by hand or copied from your fediverse server like before. The second line in `$accounts` shows that a resource doesn't have to be an `acct:`. Here, asking about the homepage leads to the same person. The part people trip on is the query string. PHP builds `$_GET` with the last value of each name, so `rel=self&rel=avatar` would turn into just `avatar`. Parsing `QUERY_STRING` by hand keeps both. The arrow function needs PHP 7.4 or newer. Now point the WebFinger path at the script. On nginx with PHP-FPM: location = /.well-known/webfinger { include fastcgi\_params; fastcgi\_param SCRIPT\_FILENAME /var/www/example.com/webfinger.php; fastcgi\_pass unix:/run/php/php8.3-fpm.sock; } The socket name changes with the PHP version, so check yours with `ls /run/php/`. On Caddy, it's a rewrite plus the PHP handler. The query string survives the rewrite: example.com { root \* /var/www/example.com rewrite /.well-known/webfinger /webfinger.php php\_fastcgi unix//run/php/php8.3-fpm.sock file\_server } ## When you want the handle itself on your domain Everything above makes your domain point to an account that lives somewhere else. If you run your own Mastodon or GoToSocial server and want the handle to be `@you@yourdomain` for real, while the server runs on a subdomain like `social.yourdomain`, both have a setting for it: - Mastodon calls it `LOCAL_DOMAIN` (the domain in the handles) and `WEB_DOMAIN` (where the server actually runs). - GoToSocial calls it `account-domain` and `host`. In both cases the main domain has to redirect WebFinger to the server. GoToSocial also wants `/.well-known/host-meta` and `/.well-known/nodeinfo` redirected. This is the Caddy version from the GoToSocial docs, on the main domain: example.com { redir /.well-known/host-meta\* https://social.example.com{uri} permanent redir /.well-known/webfinger\* https://social.example.com{uri} permanent redir /.well-known/nodeinfo\* https://social.example.com{uri} permanent } Mastodon's docs add that this redirect needs CORS headers too, or parts of the web interface break. This has to be decided before the first start. Both projects say the same thing in their docs: once your server has federated with others, the domain layout can't be safely changed, because remote servers cache the mapping and get confused. If you already have an account somewhere and only want a nicer address, the static file is the safer path. ## Making it yours Since clients ignore what they don't understand, the JRD is a good place to collect your links. Some ideas: - An avatar, with `rel` set to `http://webfinger.net/rel/avatar` and the image's `type`. - A profile page pointing to your blog instead of your fediverse profile. - `titles`, so a link can carry a name in more than one language, like `{"en": "My blog", "pt-BR": "Meu blog"}`. - An OpenID Connect issuer link, if you run your own identity provider. Pocket ID, from the first Neat Finds, is one. - Your own link types. A `rel` can be any URI, so you can make one up under your domain, like `https://example.com/rel/feed` with `type` set to `application/atom+xml` for your blog's feed. - `properties`, also named by URIs, for small facts like your display name. For a full example, look at packetizer.com, home of one of the RFC's authors. His address there returns properties with his name in English and Chinese and the date the account was created, plus links to an avatar, a profile page, his blog with a title, a vCard, his blog's Atom feed and an OpenID provider: curl -s "https://packetizer.com/.well-known/webfinger?resource=acct:paulej@packetizer.com" Here's what a personal JRD with a few of those ideas could look like. The `example.com` names are placeholders: { "subject": "acct:pablo@example.com", "aliases": \["https://example.com/"\], "properties": { "https://example.com/ns/name": "Pablo" }, "links": \[ {"rel": "self", "type": "application/activity+json", "href": "https://social.example.net/users/pablo"}, {"rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://example.com/"}, {"rel": "http://webfinger.net/rel/avatar", "type": "image/png", "href": "https://example.com/avatar.png"}, {"rel": "https://example.com/rel/feed", "type": "application/atom+xml", "href": "https://example.com/feed.xml", "titles": {"en": "Blog", "pt-BR": "Blog"}} \] } ## Testing it curl with `-i` shows the headers, which is where most mistakes hide: curl -si "https://example.com/.well-known/webfinger?resource=acct:pablo@example.com" Check the status (200), `Content-Type: application/jrd+json` and `Access-Control-Allow-Origin: *`. Then try the edge cases: curl -s -o /dev/null -w "%{http\_code}\\n" "https://example.com/.well-known/webfinger" curl -s -o /dev/null -w "%{http\_code}\\n" "https://example.com/.well-known/webfinger?resource=acct:nobody@example.com" curl -s "https://example.com/.well-known/webfinger?resource=acct:pablo@example.com&rel=self" | jq ".links\[\].rel" The first should print 400, the second 404, and the third only `"self"`. With the static file, all three give you the full answer, as described above. The final test is the human one: search for the address from a fediverse account and see if your profile shows up. ## What you're publishing Everything in that JSON is public, and the open CORS header means any web page can read it with a line of JavaScript. RFC 7033 is explicit about this: WebFinger must not be used to publish personal data unless the person explicitly agreed, and a service that publishes data about its users must let them choose what goes out. If you're answering for other people, ask them what they want in their file. Keep phone numbers and private email addresses out of it unless you want them collected. And if you run a server for many accounts, the RFC recommends taking steps against abuse, since a 404 for unknown names and a 200 for real ones is enough to check, one request at a time, who has an account.