diff --git a/packages/backend/src/misc/fetch.ts b/packages/backend/src/misc/fetch.ts
index ea3506550c..ee903a79e9 100644
--- a/packages/backend/src/misc/fetch.ts
+++ b/packages/backend/src/misc/fetch.ts
@@ -2,7 +2,7 @@ import * as http from "node:http";
 import * as https from "node:https";
 import type { URL } from "node:url";
 import CacheableLookup from "cacheable-lookup";
-import fetch from "node-fetch";
+import fetch, { RequestRedirect } from "node-fetch";
 import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
 import config from "@/config/index.js";
 import { isValidUrl } from "./is-valid-url.js";
@@ -58,6 +58,7 @@ export async function getResponse(args: {
 	headers: Record<string, string>;
 	timeout?: number;
 	size?: number;
+	redirect?: RequestRedirect;
 }) {
 	if (!isValidUrl(args.url)) {
 		throw new StatusError("Invalid URL", 400);
@@ -78,8 +79,13 @@ export async function getResponse(args: {
 		size: args.size || 10 * 1024 * 1024,
 		agent: getAgentByUrl,
 		signal: controller.signal,
+		redirect: args.redirect,
 	});
 
+	if (args.redirect === "manual" && [301, 302, 307, 308].includes(res.status)) {
+		return res;
+	}
+
 	if (!res.ok) {
 		throw new StatusError(
 			`${res.status} ${res.statusText}`,
diff --git a/packages/backend/src/remote/activitypub/request.ts b/packages/backend/src/remote/activitypub/request.ts
index 07ccbf4e82..3dbad8a97a 100644
--- a/packages/backend/src/remote/activitypub/request.ts
+++ b/packages/backend/src/remote/activitypub/request.ts
@@ -6,6 +6,7 @@ import { createSignedPost, createSignedGet } from "./ap-request.js";
 import type { Response } from "node-fetch";
 import type { IObject } from "./type.js";
 import { isValidUrl } from "@/misc/is-valid-url.js";
+import { apLogger } from "@/remote/activitypub/logger.js";
 
 export default async (user: { id: User["id"] }, url: string, object: any) => {
 	const body = JSON.stringify(object);
@@ -34,10 +35,15 @@ export default async (user: { id: User["id"] }, url: string, object: any) => {
 
 /**
  * Get ActivityPub object
- * @param user http-signature user
  * @param url URL to fetch
+ * @param user http-signature user
+ * @param redirects whether or not to accept redirects
  */
-export async function apGet(url: string, user?: ILocalUser): Promise<IObject> {
+export async function apGet(
+	url: string,
+	user?: ILocalUser,
+	redirects: boolean = true
+): Promise<IObject> {
 	if (!isValidUrl(url)) {
 		throw new StatusError("Invalid URL", 400);
 	}
@@ -61,7 +67,15 @@ export async function apGet(url: string, user?: ILocalUser): Promise<IObject> {
 			url,
 			method: req.request.method,
 			headers: req.request.headers,
+			redirect: redirects ? "manual" : "error",
 		});
+
+		if (redirects && [301, 302, 307, 308].includes(res.status)) {
+			const newUrl = res.headers.get("location");
+			if (newUrl == null) throw new Error("apGet got redirect but no target location");
+			apLogger.debug(`apGet is redirecting to ${newUrl}`);
+			return apGet(newUrl, user, false);
+		}
 	} else {
 		res = await getResponse({
 			url,
@@ -71,12 +85,20 @@ export async function apGet(url: string, user?: ILocalUser): Promise<IObject> {
 					'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
 				"User-Agent": config.userAgent,
 			},
+			redirect: redirects ? "manual" : "error",
 		});
+
+		if (redirects && [301, 302, 307, 308].includes(res.status)) {
+			const newUrl = res.headers.get("location");
+			if (newUrl == null) throw new Error("apGet got redirect but no target location");
+			apLogger.debug(`apGet is redirecting to ${newUrl}`);
+			return apGet(newUrl, undefined, false);
+		}
 	}
 
 	const contentType = res.headers.get("content-type");
 	if (contentType == null || !validateContentType(contentType)) {
-		throw new Error("Invalid Content Type");
+		throw new Error(`apGet response had unexpected content-type: ${contentType}`);
 	}
 
 	if (res.body == null) throw new Error("body is null");