A practical guide to WebFinger
WebFinger is the small protocol that lets a fediverse server find @you@yourdomain. What it is, how a request and its JSON answer look, who uses it, and three ways to set it up on your own domain: a static file, an nginx map and a short PHP script that follows RFC 7033.
Contents
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.jsonPut 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:
<?php
// webfinger.php: answers /.well-known/webfinger for the accounts listed below.
// Every resource you want to answer for, pointing to its JSON file.
$accounts = [
'acct:pablo@example.com' => __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) andWEB_DOMAIN(where the server actually runs). - GoToSocial calls it
account-domainandhost.
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
relset tohttp://webfinger.net/rel/avatarand the image'stype. - 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
relcan be any URI, so you can make one up under your domain, likehttps://example.com/rel/feedwithtypeset toapplication/atom+xmlfor 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.