Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/config/database.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -427,4 +427,20 @@
     );
 
+    -- OpenWebAuth (FEP-61cf): eenmalige tokens waarmee een BEZOEKER van elders
+    -- bewijst wie hij is. Klonkt is hier de 'target instance': we hebben nooit
+    -- iemands prive-sleutel nodig, alleen zijn publieke -- dus staat hier ook
+    -- geen geheim van een ander in.
+    --
+    -- Het token is kort houdbaar (minuten, zie OpenWebAuthService) en gaat na
+    -- inwisselen meteen weg: eenmalig is de hele bedoeling. De FEP noemt de
+    -- opruiming expliciet als DoS-verdediging -- zonder vervaltijd vult iemand
+    -- deze tabel met tokens die hij nooit inwisselt.
+    CREATE TABLE IF NOT EXISTS owa_tokens (
+      token TEXT PRIMARY KEY,
+      actor_uri TEXT NOT NULL,
+      created_at INTEGER NOT NULL          -- ms sinds epoch
+    );
+    CREATE INDEX IF NOT EXISTS idx_owa_tokens_created ON owa_tokens(created_at);
+
     CREATE TABLE IF NOT EXISTS ap_followers (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/routes/activitypub.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -130,5 +130,39 @@
 }
 
+/**
+ * Is dit een vraag naar de WORTEL van deze server?
+ *
+ * FEP-61cf laat de home instance webfingeren op "the root URL of the destination
+ * site" om ons token-endpoint te vinden. Dat is een URL en geen `acct:`, dus hij
+ * strandde hierboven op de 400 -- terwijl een ACTOR-URI met een pad wel degelijk
+ * een 400 hoort te blijven (dat legt test/webfinger-bare-host.test.js vast, en
+ * dat is een uitgesproken keuze van eerder). Vandaar: alleen origin + '/' telt,
+ * alles met een pad niet.
+ */
+function isEigenWortel(resource, req) {
+  const r = String(resource || '').trim();
+  if (!/^https?:\/\//i.test(r)) return false;
+  try {
+    const u = new URL(r);
+    if (u.pathname && u.pathname !== '/') return false;
+    if (u.search || u.hash) return false;
+    return asciiHost(u.host) === asciiHost(hostOf(req));
+  } catch { return false; }
+}
+
 router.get('/.well-known/webfinger', (req, res) => {
+  if (isEigenWortel(req.query.resource, req)) {
+    res.type('application/jrd+json; charset=utf-8');
+    res.set('Cache-Control', 'public, max-age=300');
+    return res.send(JSON.stringify({
+      subject: baseUrl(req) + '/',
+      links: [
+        // Waar een home instance ondertekend een token mag ophalen (FEP-61cf
+        // stap 2/3). Host-niveau en niet per site: het token zegt WIE er binnen
+        // is, niet waar hij binnen mag -- dat besluit valt bij de poort.
+        { rel: 'http://purl.org/openwebauth/v1', href: baseUrl(req) + '/owa/token' },
+      ],
+    }));
+  }
   const user = webfingerGebruiker(req.query.resource);
   if (!user) return res.status(400).type('text/plain').send('bad resource');
Index: src/routes/openwebauth.js
===================================================================
--- src/routes/openwebauth.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ src/routes/openwebauth.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -0,0 +1,125 @@
+/**
+ * OpenWebAuth (FEP-61cf), de drie plekken waar de stroom ons raakt.
+ *
+ *   POST /owa/login   de bezoeker geeft zijn adres, wij sturen hem naar huis
+ *   ALL  /owa/token   zijn server vraagt ondertekend om een token (stap 2/3)
+ *   GET  /owa/logout  weer anoniem
+ *
+ * Plus de middleware onderaan, die op ELK verzoek naar `?owt=` en `?zid=` kijkt.
+ *
+ * Zie OpenWebAuthService voor de stroom als geheel en voor de drie aanvallen
+ * die de FEP beschrijft.
+ */
+import express from 'express';
+import * as AP from '../services/ActivityPubService.js';
+import * as OWA from '../services/OpenWebAuthService.js';
+
+const router = express.Router();
+
+/** Waar de bezoeker weer uitkomt. Altijd binnen onze eigen origin. */
+function returnUrlFor(req, path) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  // Alleen een pad, nooit iets wat de bezoeker meegaf als volledige URL: dat is
+  // hoe een aanmeldformulier een open redirect wordt.
+  let p = String(path || '/');
+  if (!p.startsWith('/') || p.startsWith('//')) p = '/';
+  return base + p;
+}
+
+/**
+ * Stap 2/3: de home instance vraagt ondertekend om een token.
+ *
+ * GET EN POST, want de FEP zegt dat sommige home instances een POST met een
+ * willekeurig lijf sturen.
+ *
+ * De verificatie is niet nieuw geschreven: AP.verifyRequest() doet dit al voor
+ * de inbox, inclusief het vastpinnen van de sleutel op de herkomst van de actor
+ * (anders host je een document met andermans id naast je eigen sleutel), een
+ * replay-venster op Date, en een verplichte digest zodra er een lijf is. Eén
+ * implementatie van "is deze aanvraag echt van wie hij zegt".
+ */
+router.all('/owa/token', async (req, res) => {
+  let verified = null;
+  try { verified = await AP.verifyRequest(req); } catch { verified = null; }
+  if (!verified || !verified.id) {
+    return res.status(401).json({ success: false });
+  }
+  const pem = verified.publicKey && verified.publicKey.publicKeyPem;
+  if (!pem) return res.status(400).json({ success: false });
+
+  try {
+    const token = OWA.issueToken(verified.id);
+    // Versleuteld met ZIJN publieke sleutel: alleen de server die de bijbehorende
+    // prive-sleutel heeft kan hem lezen. Daarmee bewijst de teruggave dat de
+    // ondertekenaar ook echt die actor beheert, en niet alleen zijn document kent.
+    return res.json({ success: true, encrypted_token: OWA.encryptTokenFor(token, pem) });
+  } catch (e) {
+    console.warn('[owa] token uitgeven mislukte:', e && e.message);
+    return res.status(500).json({ success: false });
+  }
+});
+
+/**
+ * Stap 1: de bezoeker typt zijn adres en wij sturen hem naar zijn eigen server.
+ *
+ * We slaan hier NIETS op over wie hij zegt te zijn. Dat is opzet: pas het
+ * ingewisselde token bepaalt de identiteit (zie de impersonatie-aanval in de
+ * FEP). Een `zid`/handle is niet meer dan een routeringshint.
+ */
+router.post('/owa/login', async (req, res) => {
+  const terug = returnUrlFor(req, req.body && req.body.next);
+  const found = await OWA.discoverRedirectEndpoint(req.body && req.body.handle);
+  if (!found) {
+    const u = new URL(terug);
+    u.searchParams.set('owa_error', '1');
+    return res.redirect(u.toString());
+  }
+  return res.redirect(OWA.buildRedirect(found.endpoint, terug));
+});
+
+/** Weer anoniem. Raakt een eventuele lokale sessie niet aan. */
+router.get('/owa/logout', (req, res) => {
+  if (req.session) delete req.session.owa;
+  res.redirect(returnUrlFor(req, req.query && req.query.next));
+});
+
+/**
+ * Op elk verzoek: is er een token ingewisseld, of wil iemand de stroom starten?
+ *
+ * `owt` BEPAALT de identiteit. `zid` start hooguit de stroom en wordt verder
+ * genegeerd -- dat onderscheid IS de impersonatie-verdediging uit de FEP.
+ */
+export function owaMiddleware(req, res, next) {
+  if (req.method !== 'GET' || !req.query) return next();
+
+  if (req.query.owt) {
+    let actor = null;
+    try { actor = OWA.redeemToken(req.query.owt); } catch { actor = null; }
+    if (actor) req.session.owa = { actor, at: Date.now() };
+    // Het token uit de URL halen: hij is toch al opgebruikt, en zo blijft hij
+    // niet in de geschiedenis, de titelbalk of een gedeelde link staan.
+    const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
+    u.searchParams.delete('owt');
+    u.searchParams.delete('zid');
+    return res.redirect(u.pathname + (u.search || ''));
+  }
+
+  if (req.query.zid && !OWA.guestActor(req)) {
+    const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
+    u.searchParams.delete('zid');
+    const terug = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '') + u.pathname + (u.search || '');
+    // Geen await in een middleware-keten die verder synchroon is: bij een fout
+    // gaat de bezoeker gewoon door naar de pagina, alleen zonder inlog.
+    OWA.discoverRedirectEndpoint(req.query.zid)
+      .then((found) => {
+        if (found) return res.redirect(OWA.buildRedirect(found.endpoint, terug));
+        return res.redirect(terug);
+      })
+      .catch(() => res.redirect(terug));
+    return;
+  }
+
+  return next();
+}
+
+export default router;
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/routes/posts.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -25,4 +25,5 @@
 import { verifyBlob } from '../services/CryptoBox.js';
 import { postEntry } from '../services/PostAccessService.js';
+import * as OWA from '../services/OpenWebAuthService.js';
 import MusicMeta from '../services/MusicMeta.js';
 import { mediaDir } from '../config/paths.js';
@@ -702,5 +703,5 @@
 // tekst op, want wat niet gerenderd wordt kan ook niet lekken.
 function readerItems(site, rows, req) {
-  const viewer = { user: req.session?.user || null, site, unlockedSlug: null };
+  const viewer = OWA.viewerFor(req, site, { unlockedSlug: null });
   return rows.map((post) => ({
     post,
@@ -1589,5 +1590,8 @@
   // Anonymous visitors get a clean login gate instead of the content (the title/
   // teaser may still appear elsewhere as a teaser).
-  if (post.fan_only && !(req.session && req.session.user)) {
+  // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site
+  // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien.
+  const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req));
+  if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) {
     // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
     // stuck on the fan gate but can keep browsing.
@@ -1598,4 +1602,5 @@
       fgTitle: post.title || '',
       fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
+      owaError: !!(req.query && req.query.owa_error),
       newerPost,
       olderPost,
Index: src/server.js
===================================================================
--- src/server.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/server.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -66,4 +66,5 @@
 import ogRoutes from './routes/og.js';
 import apRoutes from './routes/activitypub.js';
+import owaRoutes, { owaMiddleware } from './routes/openwebauth.js';
 import oauthRoutes from './routes/oauth.js';
 import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
@@ -311,4 +312,10 @@
 // ActivityPub: WebFinger + /ap/* (site-agnostic, resolves the site by slug).
 app.use(apRoutes);
+// OpenWebAuth (FEP-61cf): het token-endpoint en het inlogformulier.
+app.use(owaRoutes);
+// En op elk GET-verzoek kijken of er een token wordt ingewisseld (?owt=) of een
+// stroom gestart (?zid=). Na de sessie, want het resultaat gaat IN de sessie;
+// voor de pagina's, want een poort verderop moet de uitkomst al kunnen zien.
+app.use(owaMiddleware);
 // ActivityPub C2S: OAuth 2.0 (native/web clients). Site-agnostic; the consent
 // screen picks which site the token can post as.
Index: src/services/OpenWebAuthService.js
===================================================================
--- src/services/OpenWebAuthService.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
+++ src/services/OpenWebAuthService.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -0,0 +1,199 @@
+/**
+ * OpenWebAuth (FEP-61cf) — de TARGET-kant.
+ *
+ * Waarom dit bestaat: `fan_only` betekent "mijn volgers op de fediverse", maar
+ * de poort vroeg om een KLONKT-ACCOUNT. Dat is de verkeerde vraag: precies de
+ * mensen voor wie de poort openstaat -- volgers elders -- konden er niet door,
+ * en wie er wel door kon had meestal niets met volgen te maken. Hiermee kan een
+ * bezoeker bewijzen dat hij @iemand@ergens is, zonder hier een account, een
+ * wachtwoord of een cookie van een derde partij.
+ *
+ * WIJ ZIJN DE TARGET INSTANCE, nooit de home instance. Dat is de prettige helft:
+ * de home instance heeft de prive-sleutel nodig (om te ondertekenen en om ons
+ * token te ontsleutelen), wij hebben alleen publieke sleutels nodig. Er staat
+ * hier dus geen geheim van iemand anders, en we kunnen ook niemands identiteit
+ * uitgeven. Het spiegelbeeld (Klonkt-gebruikers laten inloggen OP andere sites,
+ * de /magic-kant) is bewust NIET gebouwd: dat is een andere functie.
+ *
+ * De stroom, met de FEP-stappen erbij:
+ *   1. bezoeker geeft zijn adres        -> wij webfingeren hem, vinden zijn
+ *                                          redirect-endpoint, sturen hem daarheen
+ *   2. zijn server controleert hem      -> en vraagt ONS om een token
+ *   3. wij verifieren die ondertekende  -> token terug, versleuteld met ZIJN
+ *      aanvraag                            publieke sleutel
+ *   4. zijn server ontsleutelt          -> stuurt hem terug met ?owt=<token>
+ *   5. wij wisselen het token in        -> nu weten we wie hij is
+ *
+ * DRIE DINGEN DIE DE FEP ALS AANVAL BESCHRIJFT, en die hieronder staan omdat ze
+ * anders precies de fout worden die je niet ziet:
+ *
+ *  - IMPERSONATIE. `?zid=` mag NOOIT iemands identiteit bepalen; alleen het
+ *    ingewisselde `?owt=` telt. Mallory kan een link maken met zid=bob@elders,
+ *    en komt dan terug met een token dat MALLORY zegt. Wie zid gelooft, laat
+ *    Mallory als Bob binnen.
+ *  - OPEN REDIRECT. Het redirect-endpoint dat we uit webfinger halen moet
+ *    dezelfde host hebben als het adres dat de bezoeker intypte, anders sturen
+ *    wij bezoekers naar waar een vreemde maar wil.
+ *  - DoS. Tokens vervallen in minuten en gaan na een keer gebruiken weg.
+ */
+import crypto from 'crypto';
+import db from '../config/database.js';
+
+/** Kort, want tussen stap 3 en 5 zit alleen een redirect. De FEP noemt "a couple of minutes". */
+export const TOKEN_TTL_MS = 3 * 60 * 1000;
+
+/** rel-waarden uit de FEP. Letterlijk, want hier hangt de vindbaarheid aan. */
+export const REL_TOKEN = 'http://purl.org/openwebauth/v1';
+export const REL_REDIRECT = 'http://purl.org/openwebauth/v1#redirect';
+
+// ── tokens ────────────────────────────────────────────────────────────────
+
+/** Alles wat over tijd is weg. Draait bij elke uitgifte en elke inwisseling. */
+export function sweepTokens(now = Date.now()) {
+  db.prepare('DELETE FROM owa_tokens WHERE created_at < ?').run(now - TOKEN_TTL_MS);
+}
+
+/**
+ * Stap 3: een token voor deze actor, opgeslagen zodat we hem straks herkennen.
+ * URL-veilig, want hij reist als query-parameter terug.
+ */
+export function issueToken(actorUri, now = Date.now()) {
+  sweepTokens(now);
+  const token = crypto.randomBytes(32).toString('base64url');
+  db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
+    .run(token, String(actorUri), now);
+  return token;
+}
+
+/**
+ * Stap 5: eenmalig inwisselen. Geeft de actor terug, of null.
+ *
+ * Het verwijderen gebeurt ALTIJD, ook als het token te oud bleek: een token dat
+ * eenmaal is aangeboden mag nooit een tweede kans krijgen.
+ */
+export function redeemToken(token, now = Date.now()) {
+  const t = String(token || '');
+  if (!t) return null;
+  const row = db.prepare('SELECT actor_uri, created_at FROM owa_tokens WHERE token = ?').get(t);
+  if (row) db.prepare('DELETE FROM owa_tokens WHERE token = ?').run(t);
+  sweepTokens(now);
+  if (!row) return null;
+  if (now - row.created_at > TOKEN_TTL_MS) return null;
+  return row.actor_uri;
+}
+
+/**
+ * Het token versleuteld met de PUBLIEKE sleutel van de actor, zodat alleen zijn
+ * server het kan lezen. PKCS#1 v1.5 en base64url zonder '=' staan zo in de FEP;
+ * dat is geen smaak maar interop met Hubzilla en (streams).
+ */
+export function encryptTokenFor(token, publicKeyPem) {
+  const buf = crypto.publicEncrypt(
+    { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_PADDING },
+    Buffer.from(String(token), 'utf8'),
+  );
+  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+// ── ontdekken waar de bezoeker vandaan komt ───────────────────────────────
+
+/** `@iemand@ergens.nl`, `iemand@ergens.nl`, `acct:iemand@ergens.nl` -> {user, host}. */
+export function parseHandle(input) {
+  const m = String(input || '').trim().replace(/^acct:/i, '').replace(/^@/, '')
+    .match(/^([^@\s/]+)@([^@\s/]+)$/);
+  if (!m) return null;
+  const host = m[2].toLowerCase();
+  if (!/^[a-z0-9.-]+(:\d+)?$/i.test(host)) return null;
+  return { user: m[1], host, acct: `${m[1]}@${host}` };
+}
+
+/**
+ * Stap 1: waar stuurt deze bezoeker zich heen om zich te bewijzen?
+ *
+ * De FEP: nieuwe implementaties horen te webfingeren, oude hard-coden /magic.
+ * We doen het eerste en vallen terug op het tweede -- die terugval is veilig
+ * omdat hij per constructie op DEZELFDE host ligt.
+ *
+ * En hier staat de open-redirect-controle: wat webfinger ook teruggeeft, het
+ * moet de host zijn van het adres dat de bezoeker zelf intypte. Zonder die
+ * regel wordt dit formulier een doorgeefluik naar elke gewenste URL.
+ */
+export async function discoverRedirectEndpoint(handle, { fetchImpl = fetch } = {}) {
+  const h = parseHandle(handle);
+  if (!h) return null;
+  const url = `https://${h.host}/.well-known/webfinger?resource=${encodeURIComponent('acct:' + h.acct)}`;
+  let href = null;
+  try {
+    const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
+    if (r.ok) {
+      const jrd = await r.json();
+      const link = (jrd.links || []).find((l) => l && l.rel === REL_REDIRECT && l.href);
+      if (link) href = link.href;
+    }
+  } catch { /* geen webfinger: hieronder de terugval */ }
+  if (!href) href = `https://${h.host}/magic`;
+  try {
+    if (new URL(href).host.toLowerCase() !== h.host) return null;   // open redirect
+  } catch { return null; }
+  return { endpoint: href, handle: h };
+}
+
+/** `bdest`: de terugkeer-URL als hex, zo staat het in de FEP. */
+export function toBdest(url) {
+  return Buffer.from(String(url), 'utf8').toString('hex');
+}
+
+/**
+ * De URL waar we de bezoeker heen sturen.
+ *
+ * De terugkeer-URL moet BINNEN onze eigen origin liggen -- en het liefst binnen
+ * de PWA-scope (siteUrlBase), anders komt iemand die de site op zijn
+ * beginscherm heeft na het inloggen terecht in een losse browsertab terwijl de
+ * app uitgelogd blijft. Dat ziet eruit als "inloggen werkt niet" en is het niet.
+ */
+export function buildRedirect(endpoint, returnUrl) {
+  const u = new URL(endpoint);
+  u.searchParams.set('owa', '1');
+  u.searchParams.set('bdest', toBdest(returnUrl));
+  return u.toString();
+}
+
+// ── wie is er binnen ──────────────────────────────────────────────────────
+
+/** De actor die deze sessie bewees te zijn, of null. */
+export function guestActor(req) {
+  const g = req && req.session && req.session.owa;
+  return (g && typeof g.actor === 'string' && g.actor) ? g.actor : null;
+}
+
+/** Volgt deze actor deze site? Dat is de vraag die `fan_only` altijd al stelde. */
+export function isFollowerOf(slug, actorUri) {
+  if (!slug || !actorUri) return false;
+  const row = db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? LIMIT 1')
+    .get(String(slug), String(actorUri));
+  return !!row;
+}
+
+/**
+ * Alles wat een poort over deze bezoeker moet weten, op één plek.
+ *
+ * Bewust hier en niet in PostAccessService: die module beslist en raakt de
+ * database niet aan. Deze haalt op, die beslist.
+ */
+export function viewerFor(req, site, extra = {}) {
+  const actor = guestActor(req);
+  return {
+    user: (req && req.session && req.session.user) || null,
+    site: site || null,
+    fediActor: actor,
+    isFollower: actor && site ? isFollowerOf(site.slug, actor) : false,
+    ...extra,
+  };
+}
+
+export default {
+  TOKEN_TTL_MS, REL_TOKEN, REL_REDIRECT,
+  sweepTokens, issueToken, redeemToken, encryptTokenFor,
+  parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
+  guestActor, isFollowerOf, viewerFor,
+};
Index: src/services/PostAccessService.js
===================================================================
--- src/services/PostAccessService.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/services/PostAccessService.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -44,9 +44,12 @@
  *
  * @param post     de rij uit `posts`
- * @param viewer   { user, site, unlockedSlug } -- unlockedSlug is de slug uit
- *                 een vers ?u=-bewijs van /paid/unlock, al geverifieerd door de
- *                 aanroeper. Deze module doet geen crypto.
+ * @param viewer   { user, site, unlockedSlug, isFollower } -- unlockedSlug is de
+ *                 slug uit een vers ?u=-bewijs van /paid/unlock, al geverifieerd
+ *                 door de aanroeper; isFollower komt uit OpenWebAuthService en
+ *                 zegt dat een bewezen fedi-actor deze site volgt. Deze module
+ *                 doet geen crypto en raakt de database niet aan: ophalen doet
+ *                 de aanroeper, beslissen doet deze.
  */
-export function postAccess(post, { user = null, site = null, unlockedSlug = null } = {}) {
+export function postAccess(post, { user = null, site = null, unlockedSlug = null, isFollower = false } = {}) {
   if (!post) return 'forbidden';
 
@@ -62,5 +65,13 @@
 
   if (post.paid && String(unlockedSlug || '') !== String(post.slug)) return 'paid';
-  if (post.fan_only && !user) return 'fan';
+  // `fan_only` betekende altijd al "mijn volgers op de fediverse", maar de poort
+  // vroeg om een lokaal account -- de verkeerde vraag, die juist de mensen
+  // buitensloot voor wie hij openstond. `isFollower` is het antwoord op de
+  // ECHTE vraag: een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn
+  // en die deze site volgt. Wie dat bewijs levert, komt binnen.
+  //
+  // Het lokale account blijft er ook door, zoals het altijd deed: dat is een
+  // andere manier om te weten wie iemand is, niet een mindere.
+  if (post.fan_only && !user && !isFollower) return 'fan';
   return 'full';
 }
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/services/i18n.js	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -890,6 +890,6 @@
     'epk.recent': 'Recent',
     'fgate.title': 'Alleen voor vrienden',
-    'fgate.sub': 'Dit is exclusief voor ingelogde vrienden. Log in om het te bekijken.',
-    'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
+    'fgate.sub': 'Dit bericht is voor wie deze site volgt.',
+    'fgate.owa_label': 'Log in met je eigen fediverse-adres', 'fgate.owa_go': 'Ga verder', 'fgate.owa_hint': 'Je server bevestigt wie je bent. Hier heb je geen account en geen wachtwoord nodig — volg je deze site, dan ben je binnen.', 'fgate.owa_failed': 'Dat adres kon ik niet bereiken. Klopt de spelling?', 'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
     'lbio.empty': 'Nog geen links ingesteld.',
     'lbio.back_to_site': 'naar de site',
@@ -1896,6 +1896,6 @@
     'epk.recent': 'Recent',
     'fgate.title': 'Friends only',
-    'fgate.sub': 'This is exclusive to logged-in friends. Log in to view it.',
-    'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
+    'fgate.sub': 'This post is for people who follow this site.',
+    'fgate.owa_label': 'Sign in with your own fediverse address', 'fgate.owa_go': 'Continue', 'fgate.owa_hint': 'Your own server confirms who you are. No account and no password here — if you follow this site, you are in.', 'fgate.owa_failed': 'I could not reach that address. Is the spelling right?', 'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
     'lbio.empty': 'No links set up yet.',
     'lbio.back_to_site': 'back to the site',
@@ -2902,6 +2902,6 @@
     'epk.recent': 'Aktuell',
     'fgate.title': 'Nur für Freunde',
-    'fgate.sub': 'Dies ist exklusiv für eingeloggte Freunde. Melde dich an, um es anzusehen.',
-    'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
+    'fgate.sub': 'Dieser Beitrag ist für alle, die dieser Seite folgen.',
+    'fgate.owa_label': 'Mit deiner eigenen Fediverse-Adresse anmelden', 'fgate.owa_go': 'Weiter', 'fgate.owa_hint': 'Dein eigener Server bestätigt, wer du bist. Kein Konto und kein Passwort hier — wenn du dieser Seite folgst, bist du drin.', 'fgate.owa_failed': 'Diese Adresse war nicht erreichbar. Stimmt die Schreibweise?', 'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
     'lbio.empty': 'Noch keine Links eingerichtet.',
     'lbio.back_to_site': 'zurück zur Seite',
Index: src/views/pages/fan-gate.ejs
===================================================================
--- src/views/pages/fan-gate.ejs	(revision 9da8027b9243dfb7e0896f681a2551b9a2a3d70b)
+++ src/views/pages/fan-gate.ejs	(revision af2cc73521f992262ee6ca7ac0e77172bcaf9884)
@@ -12,5 +12,27 @@
     <% if (typeof fgTitle !== 'undefined' && fgTitle) { %><p class="fg-which">"<%= fgTitle %>"</p><% } %>
     <p class="fg-sub"><%= t('fgate.sub') %></p>
-    <p><a class="fg-btn" href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p>
+
+    <%# De hoofdweg: bewijs bij je EIGEN server wie je bent (OpenWebAuth,
+        FEP-61cf). Dit is de vraag die fan_only altijd al stelde -- volg je deze
+        site? -- in plaats van "heb je hier een account", wat juist de mensen
+        buitensloot voor wie de poort openstond. Geen account hier, geen
+        wachtwoord hier, geen cookie van een derde. %>
+    <form class="fg-owa" method="post" action="/owa/login">
+      <input type="hidden" name="next" value="<%= fgNext || '/' %>">
+      <label class="fg-label" for="fg-handle"><%= t('fgate.owa_label') %></label>
+      <div class="fg-row">
+        <input class="fg-input" id="fg-handle" name="handle" type="text" inputmode="email"
+               autocomplete="username" spellcheck="false" placeholder="@jij@jouwserver.nl" required>
+        <button class="fg-btn" type="submit"><%= t('fgate.owa_go') %></button>
+      </div>
+      <p class="fg-hint"><%= t('fgate.owa_hint') %></p>
+      <% if (typeof owaError !== 'undefined' && owaError) { %>
+        <p class="fg-error"><%= t('fgate.owa_failed') %></p>
+      <% } %>
+    </form>
+
+    <%# De lokale inlog blijft, maar als tweede: die is voor wie hier echt een
+        account heeft, en dat is de uitzondering. %>
+    <p class="fg-alt"><a href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p>
   </div>
 </section>
@@ -26,4 +48,13 @@
   .fg-which { font-style: italic; opacity: .8; margin: 0 0 12px; }
   .fg-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; }
+  .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; border: 0; font-family: inherit; font-size: 1rem; cursor: pointer; }
+  .fg-owa { margin: 0 0 18px; text-align: left; }
+  .fg-label { display: block; font-size: .85rem; opacity: .8; margin-bottom: 6px; }
+  .fg-row { display: flex; gap: 8px; flex-wrap: wrap; }
+  .fg-input { flex: 1 1 12rem; min-width: 0; padding: 12px 14px; border-radius: 10px;
+              border: 1px solid rgba(128,128,128,.35); background: transparent; color: inherit;
+              font-family: inherit; font-size: 1rem; }
+  .fg-hint { font-size: .8rem; opacity: .65; margin: 8px 0 0; line-height: 1.5; }
+  .fg-error { font-size: .85rem; margin: 8px 0 0; color: #d66; }
+  .fg-alt { margin: 0; font-size: .85rem; opacity: .7; }
 </style>
