Verified against RFC 7033; Mastodon v4.7.3 source (app/lib/webfinger.rb, webfinger_resource.rb, webfinger_serializer.rb, process_account_service.rb); Fedify CLI docs on 2026-10-15.
WebFinger Implementation
WebFinger (RFC 7033) enables user discovery by translating @username@domain handles into ActivityPub actor URLs. Without WebFinger, Mastodon and most other implementations cannot find your users. This guide covers implementing WebFinger properly.
How WebFinger Works
┌────────────────────────────────────────────────────────┐
│ WEBFINGER FLOW │
├────────────────────────────────────────────────────────┤
│ │
│ User searches: @alice@example.com │
│ ↓ │
│ Client queries: │
│ https://example.com/.well-known/webfinger │
│ ?resource=acct:alice@example.com │
│ ↓ │
│ Server returns JRD with links │
│ ↓ │
│ Client finds self link with ActivityPub type │
│ ↓ │
│ Client fetches the actor URL │
│ │
└────────────────────────────────────────────────────────┘
WebFinger Response Format
JSON Resource Descriptor (JRD)
{
"subject": "acct:alice@example.com",
"aliases": [
"https://example.com/@alice",
"https://example.com/users/alice"
],
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/users/alice"
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://example.com/@alice"
}
]
}
Required Elements
| Element | Description |
|---|---|
subject | The canonical identifier (acct: URI) |
links | Array of related resources |
links[].rel | Link relation type |
links[].type | Media type of the resource |
links[].href | URL of the resource |
The Critical Link
For ActivityPub, you must include a self link with one of the two ActivityPub media types. Mastodon's client rejects a response that has no subject or no such self link (webfinger.rb):
{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/users/alice"
}
Or with the full JSON-LD type:
{
"rel": "self",
"type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
"href": "https://example.com/users/alice"
}
Basic Implementation
Express.js Example
// Look up local accounts by lower-cased username; never build a regex from input.
app.get('/.well-known/webfinger', async (req, res) => {
res.set('Access-Control-Allow-Origin', '*'); // RFC 7033 §5: MUST
const resource = req.query.resource;
if (typeof resource !== 'string' || !resource) {
return res.status(400).end(); // RFC 7033 §4.2: missing or malformed resource
}
let username;
if (resource.startsWith('acct:')) {
// acct:alice@example.com — split on the LAST @ and compare hosts case-insensitively
const acct = resource.slice(5);
const at = acct.lastIndexOf('@');
if (at < 1) return res.status(400).end();
if (acct.slice(at + 1).toLowerCase() !== config.domain.toLowerCase()) {
return res.status(404).end(); // not our domain: never answer for other hosts
}
username = acct.slice(0, at);
} else if (/^https:\/\//i.test(resource)) {
// Some clients look up by actor or profile URL
const url = new URL(resource);
if (url.host.toLowerCase() !== config.domain.toLowerCase()) return res.status(404).end();
username = url.pathname.match(/^\/users\/([^/]+)$/)?.[1];
} else {
return res.status(400).end();
}
const user = username && await db.actors.findOne({ usernameLower: username.toLowerCase() });
if (!user) return res.status(404).end();
res.type('application/jrd+json').send(JSON.stringify({
subject: `acct:${user.preferredUsername}@${config.domain}`,
aliases: [user.id],
links: [
{ rel: 'self', type: 'application/activity+json', href: user.id },
{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: user.url },
],
}));
});
Use the account's canonical username in subject, whatever case the client sent. Only include a profile-page link if that page exists.
Response Headers
res.set('Content-Type', 'application/jrd+json');
res.set('Access-Control-Allow-Origin', '*'); // RFC 7033 §5: servers MUST send it
res.set('Cache-Control', 'public, max-age=3600');
Mastodon itself caches its WebFinger answers for 3 days, and 404/400/410 answers for 3 minutes (webfinger_controller.rb). It answers 410 Gone for permanently unavailable accounts.
What Mastodon checks on your answer
When Mastodon resolves @alice@example.com (webfinger.rb, process_account_service.rb):
- It requests
https://example.com/.well-known/webfinger?resource=acct:alice@example.comwithAccept: application/jrd+json, application/json. It does not check the response Content-Type. - On 404 it falls back to
/.well-known/host-meta(an XRD template pointing to the real WebFinger URL); on 410 it treats the account as gone; any other non-200 is an error. - The JRD must have a
subjectand aselflink with an ActivityPub type. - The
selflink'shrefmust equal the actor'sidexactly ("loops back"), and the username must match[a-z0-9_]+([.-]+[a-z0-9_]+)*. - If
subjectnames a different account than was asked for, Mastodon follows that redirect once and requires the second answer to agree.
Querying WebFinger
Client Implementation
async function lookupWebFinger(handle) {
// Parse handle: @alice@example.com or alice@example.com
const cleaned = handle.replace(/^@/, '');
const at = cleaned.lastIndexOf('@');
const username = cleaned.slice(0, at);
const domain = cleaned.slice(at + 1);
if (at < 1 || !domain || /[\/?#]/.test(domain)) {
throw new Error('Invalid handle format');
}
// RFC 7033 §4.2: HTTPS only, and percent-encode the resource
const resource = encodeURIComponent(`acct:${username}@${domain}`);
const url = `https://${domain}/.well-known/webfinger?resource=${resource}`;
const response = await fetch(url, {
headers: {
'Accept': 'application/jrd+json, application/json'
}
});
if (!response.ok) {
throw new Error(`WebFinger lookup failed: ${response.status}`);
}
const jrd = await response.json();
// Find the ActivityPub actor URL
const links = Array.isArray(jrd.links) ? jrd.links : [];
const selfLink = links.find(link =>
link.rel === 'self' &&
(link.type === 'application/activity+json' ||
link.type === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"')
);
if (!selfLink) {
throw new Error('No ActivityPub link found');
}
return {
subject: jrd.subject,
actorUrl: selfLink.href,
profileUrl: links.find(l =>
l.rel === 'http://webfinger.net/rel/profile-page'
)?.href
};
}
Usage
// Resolve a mention
const result = await lookupWebFinger('@alice@mastodon.social');
console.log(result.actorUrl);
// https://mastodon.social/users/alice
RFC 7033 §4.2 only allows redirects to https URLs; fetch follows any redirect, so check response.url starts with https:// before trusting the answer.
On a server, the domain in a handle is user input: route these requests through an SSRF-safe fetcher that refuses private addresses (see resolveAddress in the tested verification module). Then fetch the actor from actorUrl and check that its id equals actorUrl and its preferredUsername matches the handle.
Additional Links
Profile Page
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://example.com/@alice"
}
Avatar
{
"rel": "http://webfinger.net/rel/avatar",
"type": "image/png",
"href": "https://example.com/avatars/alice.png"
}
Remote Interaction Templates
Mastodon 4.7 publishes these on every account (webfinger_serializer.rb). The OStatus subscribe template is the long-standing "follow from your own server" link; the two FEP-3b86 Activity Intents templates arrived in Mastodon 4.6:
[
{ "rel": "http://ostatus.org/schema/1.0/subscribe", "template": "https://example.com/authorize_interaction?uri={uri}" },
{ "rel": "https://w3id.org/fep/3b86/Create", "template": "https://example.com/share?text={content}" },
{ "rel": "https://w3id.org/fep/3b86/Object", "template": "https://example.com/authorize_interaction?uri={object}" }
]
Handling Edge Cases
Case Insensitivity
Match usernames case-insensitively, but never by building a regular expression from the request (a . or * in the input would match other accounts, and a crafted pattern can hang the server). Store a lower-cased copy and compare that:
const user = await db.actors.findOne({ usernameLower: username.toLowerCase() });
URL Resource Lookups
Some clients query with URLs instead of acct: URIs; RFC 7033 allows any URI as the resource, and Mastodon accepts both acct: and https: resources for its own accounts (webfinger_resource.rb). The example above handles both.
Multiple Domains
If you host multiple domains:
const allowedDomains = ['example.com', 'example.org'];
app.get('/.well-known/webfinger', async (req, res) => {
const resource = req.query.resource;
const [username, domain] = parseResource(resource);
if (!allowedDomains.includes(domain)) {
return res.status(404).json({ error: 'Unknown domain' });
}
const user = await db.actors.findOne({
preferredUsername: username,
domain: domain
});
// ...
});
CORS Configuration
RFC 7033 §5 says servers MUST send Access-Control-Allow-Origin, so browser-based clients can query WebFinger:
app.use('/.well-known/webfinger', (req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Accept');
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
next();
});
Testing
Manual Test
curl "https://example.com/.well-known/webfinger?resource=acct:alice@example.com" \
-H "Accept: application/jrd+json"
Expected Response
{
"subject": "acct:alice@example.com",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/users/alice"
}
]
}
Lookup Tools
- webfinger.net — look up any resource in the browser and see the raw JRD
- webfinger.js — open-source JavaScript client with a browser demo
- Fedify CLI —
fedify webfinger @alice@example.comfrom the terminal;fedify lookup @alice@example.comfollows through to the actor
URL Encoding
RFC 7033 §4.1 says the resource value is percent-encoded. Accept both forms anyway, because clients send both (your framework's query parser decodes the encoded one):
curl "https://example.com/.well-known/webfinger?resource=acct%3Aalice%40example.com"
curl "https://example.com/.well-known/webfinger?resource=acct:alice@example.com"
Verify the Actor Fetch
WebFinger only proves discovery. Follow the self link and make sure the actor itself is served as ActivityPub:
curl -H 'Accept: application/activity+json' https://example.com/users/alice
Validation Checklist
- Returns 200 for valid users
- Returns 404 for unknown users and for other domains
- Returns 400 for a missing or malformed resource parameter
- Content-Type is
application/jrd+json - Sends
Access-Control-Allow-Origin -
selflink has an ActivityPub type and itshrefequals the actor'sid - Handles case-insensitive usernames without regex built from input
- Works with both
acct:and URL resources
Common Issues
Wrong Content-Type
RFC 7033 §4.2 requires a JRD; serve it as application/jrd+json. Mastodon happens not to check the Content-Type, but other clients do.
// Wrong: Express sends application/json
res.json(jrd);
// Right
res.type('application/jrd+json').send(JSON.stringify(jrd));
Missing CORS
Without CORS, browser-based clients can't query WebFinger:
res.setHeader('Access-Control-Allow-Origin', '*');
Domain Mismatch
The resource domain must match your server. Never answer for other hosts:
if (domain !== config.domain) {
return res.status(404).json({ error: 'Not found' });
}
Wrong Link Type
These are the only two self link types Mastodon recognises (webfinger.rb):
const validTypes = [
'application/activity+json',
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
];
Self Link Doesn't Loop Back
If the self link's href is not exactly the actor's id (a trailing slash, http vs https, a different path), Mastodon rejects the account with "does not loop back".
Caching
Cache WebFinger responses to reduce load:
async function cachedWebFinger(handle) {
const cacheKey = `webfinger:${handle}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const result = await lookupWebFinger(handle);
await redis.setex(cacheKey, 3600, JSON.stringify(result)); // 1 hour
return result;
}
Next Steps
- Testing Your Implementation - Validation
- HTTP Signatures - Authentication
- Building an Actor - Creating actors
- WebFinger sequence diagram - External reference for the discovery exchange