Verified against RFC 9421, draft-cavage-12, Mastodon 4.7.3 source, FEP-521a/fe34, GitHub security advisories; code tested in examples/verify-requests on 2026-10-15.
Origin-based security
Verifying a request tells you who sent it. It doesn't tell you whether that actor may do what the activity says. ActivityPub's answer is the same-origin policy: an actor can only speak for objects on its own origin (scheme, host and port), unless the other origin confirms the relationship.
The rules below come from FEP-fe34: Origin-based security model (status: DRAFT). It writes down what Mastodon, Mitra and other servers already enforce. Ignoring them is how the most severe ActivityPub bug to date happened: Mastodon's CVE-2024-23832 (critical, "Remote user impersonation and takeover"), fixed in February 2024.
Origins and owners
- Origin of an id:
(scheme, host, port), compared case-insensitively for scheme and host, with default ports filled in. - Owner of an object:
- for an actor, its
id - for an activity, its
actor - for a key, its
ownerorcontroller - for anything else, its
attributedTo
- for an actor, its
- An object's id and its owner's id MUST have the same origin.
Rules for incoming activities
| Activity | Rule | If it fails |
|---|---|---|
| any | activity.id (if present) is on the same origin as activity.actor | reject |
Create | object.attributedTo is the actor, and object.id is on the actor's origin | reject |
Update | object.id is on the actor's origin, and the object's owner is the actor | reject |
Delete | object (id or Tombstone) is on the actor's origin | reject |
Undo | the undone activity's actor is this actor, on the same origin | reject |
Announce, Like | may target objects on any origin, because they don't modify them | accept, but refetch an embedded object from its own id rather than trusting the copy |
Cross-origin relationships (moderators deleting, Move, keys hosted elsewhere) | need a reciprocal claim from the other origin, for example the actor document listing the key, or alsoKnownAs on the Move target | reject unless confirmed |
Trusting embedded objects
An embedded object (an object that is a full JSON object, not just an id) can be trusted only if:
- the wrapping activity is trusted, and
- the embedded object has no id or shares the wrapper's origin, and
- it has no id or the same owner as the wrapper.
Otherwise treat it as a hint and fetch it by its id from its origin, using an SSRF-safe fetch. When you fetch it, check that:
- the response
Content-Typeisapplication/activity+json, orapplication/ld+jsonwith the ActivityStreams profile (Mastodon's CVE-2024-25623 let a JSON file uploaded as media pass as an actor); - the final URL after redirects has the same origin as the returned
id.
The code
authorizeActivity implements the table above. Run it after verifyRequest and parseActivityFrom. It's tested in origin.test.js, including the CVE-2024-23832 pattern: a Create that overwrites another server's object.
// Origin-based authorization for incoming activities, following
// FEP-fe34 (https://codeberg.org/fediverse/fep/src/branch/main/fep/fe34/fep-fe34.md).
//
// Run this AFTER verifyRequest + parseActivityFrom have established that the
// signer is activity.actor. It answers: is this actor allowed to do this to
// that object, and can embedded copies be trusted or must they be refetched?
export class AuthorizationError extends Error {}
const idOf = (v) => (typeof v === 'string' ? v : v && typeof v === 'object' ? v.id : undefined);
export function origin(uri) {
const u = new URL(uri);
return `${u.protocol}//${u.host.toLowerCase()}`; // URL already normalises default ports
}
export const sameOrigin = (a, b) => origin(a) === origin(b);
/** Owner of an object per FEP-fe34: actor -> id, activity -> actor, else attributedTo. */
export function ownerOf(object) {
if (object.owner || object.controller) return idOf(object.owner || object.controller);
if (object.inbox && object.outbox) return object.id;
if (object.actor) return idOf(object.actor);
return idOf(object.attributedTo);
}
/**
* Decide what to do with an activity whose actor is already authenticated.
* Returns { trustEmbedded: boolean } — when false, refetch `object` from its
* own id (with safeFetchJson) instead of trusting the copy in the activity.
* Throws AuthorizationError when the activity must be rejected.
*/
export function authorizeActivity(activity) {
const actor = idOf(activity.actor);
if (!actor) throw new AuthorizationError('Activity has no actor');
if (activity.id && !sameOrigin(activity.id, actor)) {
throw new AuthorizationError('Activity id and actor have different origins');
}
const object = activity.object;
const objectId = idOf(object);
const embedded = object && typeof object === 'object';
switch (activity.type) {
case 'Create': {
// The creator must own the new object, on the same origin.
if (!embedded) return { trustEmbedded: false };
if (idOf(object.attributedTo) !== actor) throw new AuthorizationError('Create of an object attributed to someone else');
if (objectId && !sameOrigin(objectId, actor)) throw new AuthorizationError('Created object is on another origin');
return { trustEmbedded: true };
}
case 'Update':
case 'Delete': {
// Only the owner may modify or delete. If we only have an id, the
// object must at least live on the actor's origin.
if (!objectId) throw new AuthorizationError(`${activity.type} without an object id`);
if (!sameOrigin(objectId, actor)) throw new AuthorizationError(`${activity.type} of an object on another origin`);
if (embedded && activity.type === 'Update' && ownerOf(object) !== actor) {
throw new AuthorizationError('Update of an object owned by someone else');
}
return { trustEmbedded: embedded && activity.type === 'Update' };
}
case 'Undo': {
if (embedded && idOf(object.actor) !== actor) throw new AuthorizationError('Undo of someone else’s activity');
if (objectId && !sameOrigin(objectId, actor)) throw new AuthorizationError('Undo of an activity on another origin');
return { trustEmbedded: embedded };
}
case 'Announce':
case 'Like':
// These don't modify their object, so owners may differ — but an
// embedded object from another origin is just a claim: refetch it.
return { trustEmbedded: Boolean(embedded && objectId && sameOrigin(objectId, actor)) };
default:
return { trustEmbedded: Boolean(embedded && (!objectId || sameOrigin(objectId, actor))) };
}
}
Inbox forwarding
ActivityPub §7.1.2 asks a server that receives an activity addressed to one of its own collections (typically a followers collection), which it hasn't seen before, to forward it to the members of that collection. This is how replies reach everyone in a thread.
A forwarded activity arrives signed by the forwarding server, not by activity.actor, so parseActivityFrom correctly refuses to treat the signature as proof. Don't simply reject it, and never accept it on the forwarder's word. Instead:
- Refetch it from its origin.
GET activity.id(or the object's id) with an SSRF-safe fetch, check that the returned id is on the actor's origin, and process what you fetched. Many servers don't serve activities, only objects, so fetching the object (for example the replyNote) is the common path. - Or verify an object integrity proof. If the activity carries a FEP-8b32 proof whose verification method belongs to the actor, it's self-authenticating. Mastodon verifies these since 4.7.0 (
eddsa-jcs-2022, andmldsa44-jcs-2024with OpenSSL 3.5 or later). - Otherwise discard it.
Avoid relying on legacy Linked Data Signatures (RsaSignature2017) for this. In May 2026 the same JSON-LD named-graph restructuring bypass hit both Mastodon (CVE-2026-46349) and Fedify (CVE-2026-42462).
Serving objects safely
The same model applies when you are the origin:
- Serve media from a different origin (for example a separate subdomain) if users can upload arbitrary files, so an uploaded JSON file can never be mistaken for one of your actors.
- Serve ActivityPub documents only with an ActivityPub
Content-Type, and returnVary: Acceptif the same URL also serves HTML. - For non-public objects, require a signed GET from a key whose owner is in the object's audience.
- If your responses depend on the request signature, make sure caches don't serve one requester's response to another. That was Mastodon's CVE-2026-25540.