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.

RFC 7033

webfinger.net

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:

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

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:

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.

Gemlog

Home

Proxied content from gemini://pablomurad.com/gemlog/practical-guide-to-webfinger.gmi

Gemini request details:

Original URL
gemini://pablomurad.com/gemlog/practical-guide-to-webfinger.gmi
Status code
Success
Meta
text/gemini;lang=en
Proxied by
kineto

Be advised that no attempt was made to verify the remote SSL certificate.