Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision f79a471bf696cf519bf12755043ead300dd36476)
+++ src/middleware/render.js	(revision 74c5abc44729a4a61634ec8789305135c65b2cf9)
@@ -20,4 +20,5 @@
 import { isPremium as isPremiumInstance, premiumEnabled, premiumUnlocked } from '../services/PatreonService.js';
 import ActivityPubService from '../services/ActivityPubService.js';
+import { imgProxyUrl } from '../services/ThumbnailService.js';
 import { audioEnabled as audioFeatureEnabled } from '../config/features.js';
 
@@ -165,4 +166,13 @@
       ? `/media/thumb/${w || 480}/${url.slice(7)}`
       : url,
+    // Crisp avatars: a local /media avatar goes through the local thumb route; a REMOTE
+    // (fediverse) avatar through the signed downscaling proxy. Same downscale, the remote
+    // one is just fetched first. Default 128px (covers feed 44px → profile ~120px).
+    avatar: (url, w) => {
+      if (!url || typeof url !== 'string') return url;
+      if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 128}/${url.slice(7)}`;
+      if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 128);
+      return url;
+    },
     pageTitle: data.pageTitle || (data.site && data.site.title) || 'Klonkt',
     appVersion: APP_VERSION,
Index: src/server.js
===================================================================
--- src/server.js	(revision f79a471bf696cf519bf12755043ead300dd36476)
+++ src/server.js	(revision 74c5abc44729a4a61634ec8789305135c65b2cf9)
@@ -20,5 +20,5 @@
 import { SqliteSessionStore } from './services/SqliteSessionStore.js';
 import { ensurePrimarySite } from './services/ensurePrimarySite.js';
-import { getThumbnail, THUMB_SIZES } from './services/ThumbnailService.js';
+import { getThumbnail, getRemoteThumbnail, verifyImg, THUMB_SIZES } from './services/ThumbnailService.js';
 import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
 import { isViewer } from './middleware/auth.js';
@@ -234,4 +234,21 @@
 });
 
+// Signed remote-image proxy: downscale a REMOTE avatar/image (SSRF-safe via safeFetch)
+// to a cached WebP, so line-art fediverse avatars don't render jagged. Only HMAC-signed
+// URLs (produced by the avatar() view helper) are accepted — not an open resizer.
+app.get('/img/a/:w', async (req, res) => {
+  const w = parseInt(req.params.w, 10);
+  const url = typeof req.query.u === 'string' ? req.query.u : '';
+  const sig = typeof req.query.s === 'string' ? req.query.s : '';
+  if (!THUMB_SIZES.has(w) || !verifyImg(url, w, sig)) return res.status(400).end();
+  let file = null;
+  try { file = await getRemoteThumbnail(url, w); } catch { /* fall through to original */ }
+  if (!file) return res.redirect(302, url); // fetch/downscale failed → let the browser load the remote original
+  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+  res.setHeader('Cache-Control', isDev ? 'no-cache' : 'public, max-age=604800');
+  res.type('webp');
+  res.sendFile(file);
+});
+
 app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
   // Public media (post covers, avatars) must be cross-origin embeddable by other
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision f79a471bf696cf519bf12755043ead300dd36476)
+++ src/services/ActivityPubService.js	(revision 74c5abc44729a4a61634ec8789305135c65b2cf9)
@@ -60,5 +60,5 @@
   if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
 }
-async function safeFetch(url, opts = {}, maxRedirects = 3) {
+export async function safeFetch(url, opts = {}, maxRedirects = 3) {
   let target = url;
   for (let hop = 0; ; hop++) {
Index: src/services/ThumbnailService.js
===================================================================
--- src/services/ThumbnailService.js	(revision f79a471bf696cf519bf12755043ead300dd36476)
+++ src/services/ThumbnailService.js	(revision 74c5abc44729a4a61634ec8789305135c65b2cf9)
@@ -17,10 +17,12 @@
 import path from 'path';
 import fs from 'fs';
+import crypto from 'crypto';
 import { promisify } from 'util';
+import { safeFetch } from './ActivityPubService.js';
 
 const execFileP = promisify(execFile);
 
-// Allowed widths (whitelist → no arbitrary-size abuse). 480 ≈ 2× a grid tile (retina).
-export const THUMB_SIZES = new Set([320, 480, 640]);
+// Allowed widths (whitelist → no arbitrary-size abuse). 128 = avatars; 480 ≈ 2× a grid tile.
+export const THUMB_SIZES = new Set([128, 320, 480, 640]);
 
 let _seq = 0;
@@ -75,2 +77,86 @@
   }
 }
+
+// ── Signed remote-image proxy ─────────────────────────────────────
+// Remote avatars/images (fediverse) live on OTHER servers, so we fetch them once
+// (SSRF-safe via safeFetch), downscale them identically, and cache. The proxy URL is
+// HMAC-signed so it can't be abused as an open image-resizer: only URLs that Klonkt
+// itself rendered are accepted.
+
+let _key;
+function imgKey() {
+  if (_key) return _key;
+  _key = process.env.SESSION_SECRET || '';
+  if (!_key) {
+    try {
+      const dataDir = path.dirname(path.resolve(process.env.DATABASE_PATH || './storage/database.sqlite'));
+      _key = fs.readFileSync(path.join(dataDir, '.session-secret'), 'utf8').trim();
+    } catch { _key = 'klonkt-img-proxy'; }
+  }
+  return _key;
+}
+
+function sign(url, w) {
+  return crypto.createHmac('sha256', imgKey()).update(`${w}:${url}`).digest('hex').slice(0, 24);
+}
+
+// Signed proxy URL for a remote image (used by the avatar() view helper).
+export function imgProxyUrl(url, width) {
+  return `/img/a/${width}?u=${encodeURIComponent(url)}&s=${sign(url, width)}`;
+}
+
+export function verifyImg(url, width, sig) {
+  if (!sig || !url) return false;
+  let want;
+  try { want = sign(url, width); } catch { return false; }
+  try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)); } catch { return false; }
+}
+
+/**
+ * Fetch a remote image (SSRF-safe), downscale to `width` (lanczos → WebP), cache it.
+ * @returns {Promise<string|null>} cached path, or null.
+ */
+export async function getRemoteThumbnail(url, width) {
+  if (!THUMB_SIZES.has(width) || !ffmpegPath || !url) return null;
+  const root = mediaRoot();
+  const hash = crypto.createHash('sha256').update(url).digest('hex');
+  // Remote filenames are content-hashed (Mastodon/Klonkt) → URL-keyed cache never stales.
+  const cached = path.join(root, '.thumbs', 'remote', String(width), `${hash}.webp`);
+  if (fs.existsSync(cached)) return cached;
+
+  let buf;
+  try {
+    const r = await safeFetch(url);
+    if (!r.ok) return null;
+    if (!(r.headers.get('content-type') || '').startsWith('image/')) return null;
+    if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
+    buf = Buffer.from(await r.arrayBuffer());
+  } catch (e) {
+    console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
+    return null;
+  }
+  if (buf.length > 12 * 1024 * 1024) return null;
+
+  await fs.promises.mkdir(path.dirname(cached), { recursive: true });
+  const tmpIn = `${cached}.in-${process.pid}-${_seq++}`;
+  const tmpOut = `${cached}.out-${process.pid}-${_seq++}`;
+  try {
+    await fs.promises.writeFile(tmpIn, buf);
+    await execFileP(ffmpegPath, [
+      '-hide_banner', '-loglevel', 'error', '-y',
+      '-i', tmpIn,
+      '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
+      '-frames:v', '1',
+      '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp',
+      tmpOut,
+    ], { timeout: 20000 });
+    await fs.promises.rename(tmpOut, cached);
+    return cached;
+  } catch (e) {
+    console.warn('[thumb-remote] downscale failed for', url, '-', e.message);
+    return null;
+  } finally {
+    fs.promises.unlink(tmpIn).catch(() => {});
+    fs.promises.unlink(tmpOut).catch(() => {});
+  }
+}
Index: src/views/pages/following.ejs
===================================================================
--- src/views/pages/following.ejs	(revision f79a471bf696cf519bf12755043ead300dd36476)
+++ src/views/pages/following.ejs	(revision 74c5abc44729a4a61634ec8789305135c65b2cf9)
@@ -20,5 +20,5 @@
       <% following.forEach(function(f){ %>
         <li class="tl-foll">
-          <span class="tl-foll-av"><% if (f.icon) { %><img src="<%= f.icon %>" alt=""><% } else { %><%= (f.name || '?').charAt(0).toUpperCase() %><% } %></span>
+          <span class="tl-foll-av"><% if (f.icon) { %><img src="<%= avatar(f.icon, 128) %>" alt=""><% } else { %><%= (f.name || '?').charAt(0).toUpperCase() %><% } %></span>
           <span class="tl-foll-meta">
             <a href="<%= f.url || f.actor_uri %>" target="_blank" rel="nofollow noopener"><%= f.name || f.handle %></a>
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision f79a471bf696cf519bf12755043ead300dd36476)
+++ src/views/pages/news.ejs	(revision 74c5abc44729a4a61634ec8789305135c65b2cf9)
@@ -93,5 +93,5 @@
           <% if (p.reblog_name) { %><div class="tl-boost-by"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <strong><%= p.reblog_name %></strong> <%= t('tl.boosted') %></div><% } %>
           <div class="tl-head">
-            <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= p.author_icon %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
+            <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= avatar(p.author_icon, 128) %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
             <span class="tl-id">
               <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%= p.author_name %></a>
Index: src/views/partials/fedi-node.ejs
===================================================================
--- src/views/partials/fedi-node.ejs	(revision f79a471bf696cf519bf12755043ead300dd36476)
+++ src/views/partials/fedi-node.ejs	(revision 74c5abc44729a4a61634ec8789305135c65b2cf9)
@@ -1,5 +1,5 @@
 <%# Renders one fediverse thread node (n). Expects: n, t, canManageSite, _base, siteAvatar, formatDateTime, postSlug %>
 <div class="comment-avatar">
-  <% if (n.actor_icon) { %><img src="<%= n.actor_icon %>" alt="" loading="lazy">
+  <% if (n.actor_icon) { %><img src="<%= avatar(n.actor_icon, 128) %>" alt="" loading="lazy">
   <% } else { %><span><%= (n.actor_name || '?').charAt(0).toUpperCase() %></span><% } %>
 </div>
