Skip to main content

Verified against RFC 7033, mastodon.social WebFinger response (fetched 2026-10-15), FEP-3b86, express-rate-limit 8.7.1 on 2026-10-15.

WebFinger

WebFinger is a protocol for discovering information about users and resources using their identifier (like an email address or username). In the Fediverse, it's how servers find ActivityPub actors from a simple @user@domain address.

Specification​

How It Works​

WEBFINGER DISCOVERY FLOWUser searches:@alice@example.comServer queries:example.com/.well-known/webfinger?resource=acct:alice@example.comResponse contains:href: "https://example.com/users/alice"ActivityPub Actor Retrieved

Request Format​

URL​

GET /.well-known/webfinger?resource={resource}

Parameters​

ParameterRequiredDescription
resourceYesThe resource identifier (URL-encoded)
relNoFilter links by relation; may be repeated (RFC 7033 §4.3)

Missing or malformed resource → 400 Bad Request; nothing known about it → 404 Not Found (§4.2). Clients MUST query over HTTPS only.

Resource Format​

For users, use the acct: URI scheme:

acct:username@domain

Examples:

  • acct:alice@example.com
  • acct:bob@mastodon.social

Mastodon also answers for an actor's https:// URL as the resource, and matches usernames case-insensitively — it returns the canonical spelling in subject (querying acct:gargron@mastodon.social yields "subject": "acct:Gargron@mastodon.social"). Use the returned subject, not what you typed, as the handle.

Response Format​

This mirrors what Mastodon 4.7 returns (fetched from mastodon.social, October 2026):

{
"subject": "acct:alice@example.com",
"aliases": [
"https://example.com/@alice",
"https://example.com/users/alice"
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://example.com/@alice"
},
{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/users/alice"
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "https://example.com/authorize_interaction?uri={uri}"
},
{
"rel": "https://w3id.org/fep/3b86/Object",
"template": "https://example.com/authorize_interaction?uri={object}"
},
{
"rel": "http://webfinger.net/rel/avatar",
"type": "image/png",
"href": "https://example.com/avatars/alice.png"
}
]
}

Properties​

PropertyDescription
subjectThe canonical identifier for the resource (may differ from the query)
aliasesOther URIs for the same entity
propertiesOptional name/value pairs
linksRelated resources; each has rel and usually type/href (§4.4)

template is not an RFC 7033 link member — it is a fediverse convention (from OStatus) for URL templates, used by the subscribe and FEP-3b86 links.

RelationPurpose
selfThe ActivityPub actor (with type application/activity+json, or application/ld+json; profile="https://www.w3.org/ns/activitystreams")
http://webfinger.net/rel/profile-pageHuman-readable profile
http://ostatus.org/schema/1.0/subscribeRemote-follow / interact URL template
https://w3id.org/fep/3b86/…FEP-3b86 Activity Intents (Mastodon 4.6+)
http://webfinger.net/rel/avatarAvatar image

Implementation​

Server Endpoint​

app.get('/.well-known/webfinger', async (req, res) => {
const resource = req.query.resource;

if (!resource) {
return res.status(400).json({ error: 'Missing resource parameter' });
}

// Parse acct: URI
const match = resource.match(/^acct:([^@]+)@(.+)$/);
if (!match) {
return res.status(400).json({ error: 'Invalid resource format' });
}

const [, requested, domain] = match;

// Only answer for our own domain
if (domain.toLowerCase() !== 'example.com') {
return res.status(404).end();
}

// Case-insensitive lookup; respond with the stored, canonical username
const user = await db.users.findByUsernameCaseInsensitive(requested);
if (!user) {
return res.status(404).end();
}
const username = user.username;

res.set('Access-Control-Allow-Origin', '*');
res.type('application/jrd+json').send(JSON.stringify({
subject: `acct:${username}@example.com`,
aliases: [
`https://example.com/@${username}`,
`https://example.com/users/${username}`
],
links: [
{
rel: 'http://webfinger.net/rel/profile-page',
type: 'text/html',
href: `https://example.com/@${username}`
},
{
rel: 'self',
type: 'application/activity+json',
href: `https://example.com/users/${username}`
},
{
rel: 'http://ostatus.org/schema/1.0/subscribe',
template: 'https://example.com/authorize_interaction?uri={uri}'
}
]
}));
});

Client Query​

async function lookupUser(handle) {
// Parse handle: @alice@example.com or alice@example.com
const match = handle.replace(/^@/, '').match(/^([^@]+)@(.+)$/);
if (!match) {
throw new Error('Invalid handle format');
}

const [, username, domain] = match;
const resource = `acct:${username}@${domain}`;

// Query WebFinger
const url = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;

// `domain` comes from user input: on a server, use an SSRF-safe fetch
// (public addresses only, size and time limits) — see /docs/security/verifying-requests
const response = await fetch(url, {
headers: { 'Accept': 'application/jrd+json' },
redirect: 'follow'
});

if (!response.ok) {
throw new Error(`WebFinger failed: ${response.status}`);
}

const data = await response.json();

// Find the ActivityPub actor; servers use either media type
const AP_TYPES = [
'application/activity+json',
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
];
const actorLink = (data.links ?? []).find(
(link) => link.rel === 'self' && AP_TYPES.includes(link.type)
);

if (!actorLink) {
throw new Error('No ActivityPub actor found');
}

return actorLink.href;
}

Content Types​

Request​

GET /.well-known/webfinger?resource=acct:alice@example.com HTTP/1.1
Host: example.com
Accept: application/jrd+json

Response​

HTTP/1.1 200 OK
Content-Type: application/jrd+json
Access-Control-Allow-Origin: *

{ ... }

CORS​

WebFinger servers MUST include the Access-Control-Allow-Origin header, and SHOULD set it to * (RFC 7033 §5), so browser-based clients can query them:

app.use('/.well-known/webfinger', (req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET');
res.set('Access-Control-Allow-Headers', 'Accept');
next();
});

Error Handling​

RFC 7033 defines only the status codes — 400 for a missing or malformed resource, 404 when the server has no information — and no error body format (§4.2). Clients should rely on the status code, not on a body.

Caching​

WebFinger responses can be cached. mastodon.social sends Cache-Control: max-age=259200, public (3 days):

res.set('Cache-Control', 'max-age=259200, public');

If your handles live on a different domain from your server (Mastodon's LOCAL_DOMAIN vs WEB_DOMAIN), see split domains in Keys and Identity.

Security​

  1. HTTPS required - RFC 7033 requires HTTPS; clients MUST NOT fall back to HTTP (§4.2, §9.1)
  2. Validate domains - Only respond for your domain
  3. Rate limiting - Prevent enumeration attacks
const rateLimit = require('express-rate-limit');

app.use('/.well-known/webfinger', rateLimit({
windowMs: 60 * 1000,
limit: 100 // express-rate-limit 8 (`max` is the old name)
}));

Testing​

curl​

curl "https://mastodon.social/.well-known/webfinger?resource=acct:Gargron@mastodon.social"

Online Tools​

Common Issues​

CORS Errors​

Add CORS headers to your endpoint.

Wrong Content-Type​

Use application/jrd+json, not application/json.

Domain Mismatch​

Verify the domain in the resource matches your server.

Next Steps​