Verified against W3C ActivityPub REC §5-7, RFC 7033, draft-cavage-http-signatures-12, Mastodon v4.7.3 source (delivery_worker.rb, signed_request.rb, block.rb) on 2026-10-15.
Understanding Federation
Federation is what makes the Fediverse work. It's the process by which independent servers discover each other, exchange information, and deliver content across the network. This guide explains how it all fits together.
The Federation Flow
When a user on Server A interacts with content from Server B, here's what happens:
Step 1: Discovery with WebFinger
When you search for a user like @bob@server-b.com, your server needs to find Bob's profile. This is where WebFinger comes in.
WebFinger Request
GET /.well-known/webfinger?resource=acct:bob@server-b.com HTTP/1.1
Host: server-b.com
Accept: application/jrd+json
WebFinger Response
{
"subject": "acct:bob@server-b.com",
"aliases": [
"https://server-b.com/@bob",
"https://server-b.com/users/bob"
],
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://server-b.com/users/bob"
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "https://server-b.com/@bob"
}
]
}
The rel: self link with type: application/activity+json tells us where to fetch the ActivityPub actor.
Step 2: Fetching the Actor
With the actor URL from WebFinger, your server fetches the full actor profile:
Actor Request
GET /users/bob HTTP/1.1
Host: server-b.com
Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json
Actor Response
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
"type": "Person",
"id": "https://server-b.com/users/bob",
"inbox": "https://server-b.com/users/bob/inbox",
"outbox": "https://server-b.com/users/bob/outbox",
"followers": "https://server-b.com/users/bob/followers",
"following": "https://server-b.com/users/bob/following",
"preferredUsername": "bob",
"name": "Bob",
"publicKey": {
"id": "https://server-b.com/users/bob#main-key",
"owner": "https://server-b.com/users/bob",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n..."
}
}
Key information obtained:
- inbox: Where to send activities to Bob
- outbox: Where to fetch Bob's posts
- publicKey: Used to verify signatures from Bob's server
Step 3: Delivering Activities
When Alice wants to follow Bob, her server sends a Follow activity to Bob's inbox.
The Follow Activity
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"id": "https://server-a.com/activities/123",
"actor": "https://server-a.com/users/alice",
"object": "https://server-b.com/users/bob"
}
HTTP Signature
The ActivityPub spec doesn't define an authentication mechanism, but in practice every major server requires inbox deliveries to be signed with HTTP Signatures. The signature proves the request came from Server A.
POST /users/bob/inbox HTTP/1.1
Host: server-b.com
Date: Sun, 15 Jan 2024 10:30:00 GMT
Digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=
Signature: keyId="https://server-a.com/users/alice#main-key",
algorithm="rsa-sha256",
headers="(request-target) host date digest",
signature="..."
Content-Type: application/activity+json
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
...
}
Step 4: Signature Verification
When Server B receives the activity, it must verify the signature:
1. Extract keyId from Signature header
→ https://server-a.com/users/alice#main-key
2. Fetch the public key
GET https://server-a.com/users/alice
→ Find the key whose id equals keyId and read its owner
(the owner must be the activity's actor)
3. Check the Signature's headers list covers (request-target),
host, date and digest, that the Digest matches the raw body,
and that Date is recent
4. Reconstruct the signing string
(request-target): post /users/bob/inbox
host: server-b.com
date: Sun, 15 Jan 2024 10:30:00 GMT
digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=
5. Verify the signature using the public key
→ If valid, process the activity
→ If invalid, reject with 401/403
Step 5: Processing and Response
After verifying the signature, Server B processes the Follow:
- Check if actor exists - Does Bob exist on this server?
- Check blocks - Is Alice blocked by Bob or the server?
- Auto-accept or queue - Depending on Bob's settings
- Send Accept - Notify Alice that the follow was accepted
Accept Activity
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Accept",
"id": "https://server-b.com/activities/456",
"actor": "https://server-b.com/users/bob",
"object": {
"type": "Follow",
"id": "https://server-a.com/activities/123",
"actor": "https://server-a.com/users/alice",
"object": "https://server-b.com/users/bob"
}
}
This Accept is sent to Alice's inbox, also with HTTP Signatures.
Content Delivery
Once Alice follows Bob, she'll receive Bob's public posts. Here's how a post gets delivered:
1. Bob Creates a Post
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://server-b.com/activities/789",
"actor": "https://server-b.com/users/bob",
"object": {
"type": "Note",
"id": "https://server-b.com/notes/1",
"content": "Hello, Fediverse!",
"attributedTo": "https://server-b.com/users/bob",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://server-b.com/users/bob/followers"]
},
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://server-b.com/users/bob/followers"]
}
2. Server B Determines Recipients
Looking at to and cc:
as:Public- Public post: anyone may read it, but it is not a delivery target — servers MUST NOT deliver to the Public collection. Delivery still goes only to the listed actors and collectionsbob/followers- Expand to list of follower inboxes
3. Deduplicate and Batch
If multiple users on Server A follow Bob, the post is only delivered once to Server A's shared inbox.
Bob's followers on different servers:
- alice@server-a.com → server-a.com/inbox
- carol@server-a.com → server-a.com/inbox (same!)
- dave@server-c.com → server-c.com/users/dave/inbox
Delivery targets:
- server-a.com/inbox (1 delivery for 2 users)
- server-c.com/users/dave/inbox
4. Deliver to Each Inbox
Server B sends the Create activity to each unique inbox.
Shared Inboxes
To reduce load, servers can advertise a shared inbox:
{
"type": "Person",
"id": "https://server-a.com/users/alice",
"inbox": "https://server-a.com/users/alice/inbox",
"endpoints": {
"sharedInbox": "https://server-a.com/inbox"
}
}
When delivering to multiple users on the same server, use the shared inbox.
Handling Failures
Federation isn't always reliable, so servers queue deliveries and retry them. As one real example, Mastodon retries a failed delivery up to 16 times with a delay of roughly n⁴ + 15 seconds plus jitter, which adds up to about 2–3 days before it gives up (delivery_worker.rb). It doesn't retry most 4xx responses, and it stops delivering to a server altogether once deliveries have failed on 7 separate days.
The essentials:
- Persist the queue so deliveries survive restarts
- Back off between attempts, and stop on responses that will never succeed (e.g. 404, 410)
- Track dead servers so they don't tie up your workers
Delivery at Scale covers this in detail.
Object Fetching
Sometimes servers need to fetch objects they haven't seen:
Scenario: Reply to Unknown Post
Alice receives a reply to a post she hasn't seen:
{
"type": "Create",
"object": {
"type": "Note",
"inReplyTo": "https://server-c.com/notes/unknown",
"content": "Great point!"
}
}
Server A can fetch the parent:
GET /notes/unknown HTTP/1.1
Host: server-c.com
Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json
This allows servers to reconstruct conversation threads.
Privacy Considerations
What Gets Federated
| Visibility | Who Receives |
|---|---|
| Public | All servers with interested followers |
| Unlisted | Same as public, but not in public timelines |
| Followers-only | Only servers with followers |
| Direct | Only mentioned users' servers |
What Stays Local
- Mute lists
- Who you block, beyond the blocked account's own server — Mastodon sends a
Blockactivity to the blocked account's server, so that server does learn about it (block_service.rb) - Draft posts
- User settings
- Private notes
Common Federation Issues
Signature Verification Failures
Causes:
- Clock skew or stale requests (Mastodon treats a signature without
expiresas valid for 5 minutes after creation, plus 1 hour of tolerated skew, so about 65 minutes; and never more than 1 hour in the future) - Incorrect key format
- Wrong signing algorithm
Solutions:
- Use NTP to sync server clocks
- Verify the key is a valid PEM-encoded public key
- Sign with
rsa-sha256(Mastodon also acceptshs2019), and see HTTP Signatures for RFC 9421
Delivery Failures
Causes:
- Server unreachable
- Inbox endpoint returns errors
- Rate limiting
Solutions:
- Retry with backoff, and treat 404/410 as final
- Respect
429responses - Monitor delivery success rates
Stale Data
Causes:
- Actor info cached too long
- Object references to deleted content
Solutions:
- Cache actors for a limited time and refetch when a signature fails to verify (the key may have rotated)
- Respect HTTP cache headers
- Handle 410 Gone responses
Next Steps
- HTTP Signatures - Deep dive into signing
- Delivery - ActivityPub delivery rules
- Building an Actor - Implement federation