A practical guide to WebFinger
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:
subjectis the URI the document describes. The RFC says it SHOULD be there.aliasesis a list of other URIs for the same thing.propertiesholds extra facts about the subject, as name and value pairs where each name is a URI.linksis where most of the work happens. Each link has arel(the only required field, saying what kind of link it is), and optionally atype, anhref,titlesin different languages and its ownproperties.
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:
- HTTPS only. Clients must query over HTTPS, and the resource must not be served over plain HTTP. Redirects are allowed, but only to another
httpsaddress. - The query must have a
resourceparameter. If it's missing or malformed, the answer is 400 Bad Request. - If the server doesn't know the resource, the answer is 404.
- Responses must carry the
Access-Control-Allow-Originheader, so code running in a browser can read them. In practice that'sAccess-Control-Allow-Origin: *. - Servers should support the
relparameter. A client can send one or morerelvalues, and the server returns only the links that match. The subject, aliases and properties come back either way. - Clients must ignore any member of the JRD they don't understand. That's what lets you add your own fields without breaking anyone.
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:
self, with typeapplication/activity+json, pointing to the ActivityPub actor. This is the link Mastodon really needs. A minimal valid answer for Mastodon is a subject plus this one link.http://webfinger.net/rel/profile-page, the HTML profile a person would open in a browser.http://ostatus.org/schema/1.0/subscribe, with atemplatefield instead ofhref. Other servers use it to send you to your own instance to follow someone.
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:
- Every question gets the same answer. Ask about
anyone@yourdomainand you get your account. Ask with noresourceat all and you still get a 200 instead of the 400 the spec asks for. - The
relfilter is ignored, so clients always get every link. - Your domain becomes a pointer, and your handle doesn't change. People can find you as
@pablo@pablomurad.com, but your posts still show@pablo@murad.social, because that's the subject in the answer.
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: