Index: src/services/PaidPatreonService.js
===================================================================
--- src/services/PaidPatreonService.js	(revision 9019a3e8f59ad6d0b916a68ee153559d27f8c0e8)
+++ src/services/PaidPatreonService.js	(revision c7a211d878f951ae8b88371deb9911d892a9581b)
@@ -135,17 +135,30 @@
 }
 
-// Pure: pick the membership for the owner's campaign out of a Patreon
+// Pure: pick the owner's-campaign membership out of a Patreon
 // identity?include=memberships.campaign response (JSON:API). Returns
 // { status, cents } or null.
+//
+// Memberships returned via a creator's OWN OAuth client are already scoped to
+// that creator's campaign(s), so in practice there is one. We still prefer an
+// exact campaign_id match (belt and suspenders for multi-campaign creators),
+// but fall back to the sole membership when the configured campaign_id doesn't
+// match: a wrong/typo'd campaign_id in the admin must not lock out real patrons.
 export function pickCampaignMembership(identity, campaignId) {
   const inc = (identity && identity.included) || [];
+  const members = [];
   for (const it of inc) {
     if (it.type !== 'member') continue;
     const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
-    if (!camp || String(camp.id) !== String(campaignId)) continue;
     const a = it.attributes || {};
-    return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
+    members.push({
+      status: a.patron_status || null,
+      cents: a.currently_entitled_amount_cents || 0,
+      campaignId: camp ? String(camp.id) : null,
+    });
   }
-  return null;
+  if (!members.length) return null;
+  const exact = members.find((m) => m.campaignId && String(m.campaignId) === String(campaignId));
+  const pick = exact || (members.length === 1 ? members[0] : null);
+  return pick ? { status: pick.status, cents: pick.cents } : null;
 }
 
@@ -173,5 +186,19 @@
   if (!idRes.ok) return null;
   const identity = await idRes.json();
-  return pickCampaignMembership(identity, c.campaignId);   // token goes out of scope, discarded
+  const membership = pickCampaignMembership(identity, c.campaignId);   // token goes out of scope, discarded
+  // Diagnostic (no identity stored, only shapes): why did a real supporter get
+  // rejected? Logs the memberships Patreon returned vs the configured campaign.
+  // Nothing here is persisted; it's a one-line server log for the owner.
+  if (!membership || membership.status !== 'active_patron') {
+    const seen = ((identity && identity.included) || [])
+      .filter((it) => it.type === 'member')
+      .map((it) => {
+        const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
+        const a = it.attributes || {};
+        return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`;
+      });
+    console.warn(`[paid] verifyPatron: config campaign=${c.campaignId} → memberships seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`);
+  }
+  return membership;
 }
 
Index: test/paid-patron.test.js
===================================================================
--- test/paid-patron.test.js	(revision 9019a3e8f59ad6d0b916a68ee153559d27f8c0e8)
+++ test/paid-patron.test.js	(revision c7a211d878f951ae8b88371deb9911d892a9581b)
@@ -27,5 +27,5 @@
 });
 
-test('pickCampaignMembership finds the right campaign, ignores others', async () => {
+test('pickCampaignMembership: exact campaign match wins', async () => {
   const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
   const id = identityWith('42', 'active_patron', 500);
@@ -33,5 +33,32 @@
   assert.equal(m.status, 'active_patron');
   assert.equal(m.cents, 500);
-  assert.equal(pickCampaignMembership(id, '999'), null);   // different campaign
+});
+
+test('pickCampaignMembership: sole membership is used even if campaign_id is wrong (creator-scoped)', async () => {
+  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
+  const id = identityWith('42', 'active_patron', 500);
+  // A typo'd campaign_id must not lock out a real patron: memberships from the
+  // owner's own client are already their campaign, so fall back to the sole one.
+  const m = pickCampaignMembership(id, '999');
+  assert.equal(m.status, 'active_patron');
+  assert.equal(m.cents, 500);
+});
+
+test('pickCampaignMembership: multiple memberships + no match → null (no silent grant)', async () => {
+  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
+  const id = {
+    data: { type: 'user', id: 'u', relationships: { memberships: { data: [{ type: 'member', id: 'm1' }, { type: 'member', id: 'm2' }] } } },
+    included: [
+      { type: 'member', id: 'm1', attributes: { patron_status: 'active_patron', currently_entitled_amount_cents: 300 }, relationships: { campaign: { data: { type: 'campaign', id: '42' } } } },
+      { type: 'member', id: 'm2', attributes: { patron_status: 'active_patron', currently_entitled_amount_cents: 800 }, relationships: { campaign: { data: { type: 'campaign', id: '77' } } } },
+    ],
+  };
+  assert.equal(pickCampaignMembership(id, '999'), null);      // ambiguous, refuse
+  assert.equal(pickCampaignMembership(id, '77').cents, 800);  // exact still works
+});
+
+test('pickCampaignMembership: no memberships → null', async () => {
+  const { pickCampaignMembership } = await import('../src/services/PaidPatreonService.js');
+  assert.equal(pickCampaignMembership({ data: {}, included: [] }, '42'), null);
 });
 
