Index: src/assets/vendor/simplewebauthn-browser.umd.min.js
===================================================================
--- src/assets/vendor/simplewebauthn-browser.umd.min.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/assets/vendor/simplewebauthn-browser.umd.min.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,2 @@
+/* [@simplewebauthn/browser@13.3.0] */
+!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).SimpleWebAuthnBrowser={})}(this,(function(e){"use strict";function t(e){const t=new Uint8Array(e);let r="";for(const e of t)r+=String.fromCharCode(e);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function r(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),o=atob(n),i=new ArrayBuffer(o.length),a=new Uint8Array(i);for(let e=0;e<o.length;e++)a[e]=o.charCodeAt(e);return i}function n(){return o.stubThis(void 0!==globalThis?.PublicKeyCredential&&"function"==typeof globalThis.PublicKeyCredential)}const o={stubThis:e=>e};function i(e){const{id:t}=e;return{...e,id:r(t),transports:e.transports}}function a(e){return"localhost"===e||/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(e)}class s extends Error{constructor({message:e,code:t,cause:r,name:n}){super(e,{cause:r}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=n??r.name,this.code=t}}const l=new class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){const e=new Error("Cancelling existing WebAuthn API call for new one");e.name="AbortError",this.controller.abort(e)}const e=new AbortController;return this.controller=e,e.signal}cancelCeremony(){if(this.controller){const e=new Error("Manually cancelling existing WebAuthn API call");e.name="AbortError",this.controller.abort(e),this.controller=void 0}}},c=["cross-platform","platform"];function u(e){if(e&&!(c.indexOf(e)<0))return e}function d(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them.\n`,t)}function h(){if(!n())return p.stubThis(new Promise((e=>e(!1))));const e=globalThis.PublicKeyCredential;return void 0===e?.isConditionalMediationAvailable?p.stubThis(new Promise((e=>e(!1)))):p.stubThis(e.isConditionalMediationAvailable())}const p={stubThis:e=>e};e.WebAuthnAbortService=l,e.WebAuthnError=s,e._browserSupportsWebAuthnAutofillInternals=p,e._browserSupportsWebAuthnInternals=o,e.base64URLStringToBuffer=r,e.browserSupportsWebAuthn=n,e.browserSupportsWebAuthnAutofill=h,e.bufferToBase64URLString=t,e.platformAuthenticatorIsAvailable=function(){return n()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise((e=>e(!1)))},e.startAuthentication=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useBrowserAutofill:c=!1,verifyBrowserAutofillInput:d=!0}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");let p;0!==o.allowCredentials?.length&&(p=o.allowCredentials?.map(i));const f={...o,challenge:r(o.challenge),allowCredentials:p},b={};if(c){if(!await h())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&d)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');b.mediation="conditional",f.allowCredentials=[]}let R;b.publicKey=f,b.signal=l.createNewAbortSignal();try{R=await navigator.credentials.get(b)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rpId!==t)return new s({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:b})}if(!R)throw new Error("Authentication was not completed");const{id:g,rawId:w,response:A,type:E}=R;let m;return A.userHandle&&(m=t(A.userHandle)),{id:g,rawId:t(w),response:{authenticatorData:t(A.authenticatorData),clientDataJSON:t(A.clientDataJSON),signature:t(A.signature),userHandle:m},type:E,clientExtensionResults:R.getClientExtensionResults(),authenticatorAttachment:u(R.authenticatorAttachment)}},e.startRegistration=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useAutoRegister:c=!1}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");const h={...o,challenge:r(o.challenge),user:{...o.user,id:r(o.user.id)},excludeCredentials:o.excludeCredentials?.map(i)},p={};let f;c&&(p.mediation="conditional"),p.publicKey=h,p.signal=l.createNewAbortSignal();try{f=await navigator.credentials.create(p)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if("ConstraintError"===e.name){if(!0===r.authenticatorSelection?.requireResidentKey)return new s({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if("conditional"===t.mediation&&"required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if("required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if("InvalidStateError"===e.name)return new s({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("NotSupportedError"===e.name)return 0===r.pubKeyCredParams.filter((e=>"public-key"===e.type)).length?new s({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new s({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rp.id!==t)return new s({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new s({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:p})}if(!f)throw new Error("Registration was not completed");const{id:b,rawId:R,response:g,type:w}=f;let A,E,m,y;if("function"==typeof g.getTransports&&(A=g.getTransports()),"function"==typeof g.getPublicKeyAlgorithm)try{E=g.getPublicKeyAlgorithm()}catch(e){d("getPublicKeyAlgorithm()",e)}if("function"==typeof g.getPublicKey)try{const e=g.getPublicKey();null!==e&&(m=t(e))}catch(e){d("getPublicKey()",e)}if("function"==typeof g.getAuthenticatorData)try{y=t(g.getAuthenticatorData())}catch(e){d("getAuthenticatorData()",e)}return{id:b,rawId:t(R),response:{attestationObject:t(g.attestationObject),clientDataJSON:t(g.clientDataJSON),transports:A,publicKeyAlgorithm:E,publicKey:m,authenticatorData:y},type:w,clientExtensionResults:f.getClientExtensionResults(),authenticatorAttachment:u(f.authenticatorAttachment)}}}));
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ src/config/database.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -323,4 +323,16 @@
       default_min_cents INTEGER DEFAULT 0,
       updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    -- One row per passkey. NO patron identity is stored (design decision):
+    -- {passkey, site, proven cents, expiry}. Not traceable to a person.
+    CREATE TABLE IF NOT EXISTS paid_entitlements (
+      credential_id TEXT PRIMARY KEY,   -- WebAuthn credential id (opaque, base64url)
+      site_id TEXT NOT NULL,
+      public_key TEXT NOT NULL,         -- COSE public key, base64url
+      counter INTEGER DEFAULT 0,
+      transports TEXT,
+      min_cents INTEGER DEFAULT 0,      -- the amount proven at link time
+      expires_at INTEGER NOT NULL,      -- unix seconds; re-link after
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
     );
     CREATE TABLE IF NOT EXISTS ap_outbox (
Index: src/routes/paid.js
===================================================================
--- src/routes/paid.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/routes/paid.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,97 @@
+/**
+ * Paid posts (klonkt-demo-aki) slice 3: the patron link + passkey flow.
+ * Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in
+ * signed blobs (CryptoBox), never a session.
+ *
+ * GET  /paid/link?post=<slug>  -> redirect to Patreon authorize
+ * GET  /paid/callback          -> verify patron, render the passkey page
+ * POST /paid/register          -> verify the passkey, store the entitlement
+ */
+import express from 'express';
+import db from '../config/database.js';
+import { renderPage } from '../middleware/render.js';
+import { premiumUnlocked } from '../services/PatreonService.js';
+import { signBlob, verifyBlob, cryptoBoxReady } from '../services/CryptoBox.js';
+import PaidPatreon from '../services/PaidPatreonService.js';
+import Passkey from '../services/PasskeyService.js';
+
+const router = express.Router();
+const AUTHORIZE = 'https://www.patreon.com/oauth2/authorize';
+
+const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
+
+// The feature is only live when premium is on, secrets can be encrypted, and the
+// owner has connected a campaign.
+function ready(req, res) {
+  const site = res.locals.site;
+  if (!site) { res.status(404).end(); return null; }
+  if (!premiumUnlocked() || !cryptoBoxReady()) { res.status(404).end(); return null; }
+  const cfg = PaidPatreon.getOwnerConfig(site.id);
+  if (!cfg || !cfg.clientId || !cfg.campaignId) { res.status(404).end(); return null; }
+  return { site, cfg };
+}
+
+// Step 1: send the visitor to Patreon.
+router.get('/link', (req, res) => {
+  const r = ready(req, res); if (!r) return;
+  const slug = String(req.query.post || '').trim();
+  const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
+  if (!post || !post.paid) return res.redirect((res.locals.siteUrlBase || '') + '/' + (slug || ''));
+  const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
+  const state = signBlob({ purpose: 'patron', siteId: r.site.id, cents, post: post.slug }, 900);
+  const url = `${AUTHORIZE}?response_type=code&client_id=${encodeURIComponent(r.cfg.clientId)}`
+    + `&redirect_uri=${encodeURIComponent(baseUrl(req) + '/paid/callback')}`
+    + `&scope=${encodeURIComponent('identity identity.memberships')}`
+    + `&state=${encodeURIComponent(state)}`;
+  res.redirect(url);
+});
+
+// Step 2: Patreon returns. Verify the patron; if a supporter at the right tier,
+// render the passkey-creation page.
+router.get('/callback', async (req, res) => {
+  const r = ready(req, res); if (!r) return;
+  const payload = verifyBlob(String(req.query.state || ''));
+  if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
+    return res.status(400).send('Ongeldige of verlopen aanvraag. Probeer opnieuw vanaf de post.');
+  }
+  const code = String(req.query.code || '');
+  if (req.query.error || !code) {
+    return renderPage(req, res, 'pages/paid-result', { pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'declined', postSlug: payload.post });
+  }
+  const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
+  const cents = membership ? (membership.cents || 0) : 0;
+  const active = membership && membership.status === 'active_patron';
+  if (!active || cents < payload.cents) {
+    return renderPage(req, res, 'pages/paid-result', {
+      pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false,
+      reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post,
+    });
+  }
+  // Supporter at the right tier. Hand out registration options + a signed blob
+  // carrying the challenge and the proven cents; the passkey page returns both.
+  const options = await Passkey.registrationOptions(baseUrl(req), r.site.slug);
+  const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
+  renderPage(req, res, 'pages/paid-passkey', {
+    pageTitle: 'Maak je passkey', bodyClass: 'on-special',
+    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
+  });
+});
+
+// Step 3: verify the passkey and store the pseudonymous entitlement.
+router.post('/register', express.json({ limit: '64kb' }), async (req, res) => {
+  const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
+  const { response, blob } = req.body || {};
+  const payload = verifyBlob(String(blob || ''));
+  if (!payload || payload.purpose !== 'reg' || payload.siteId !== r.site.id) {
+    return res.status(400).json({ error: 'bad_challenge' });
+  }
+  const cred = await Passkey.verifyRegistration(baseUrl(req), response, payload.challenge);
+  if (!cred) return res.status(400).json({ error: 'verify_failed' });
+  Passkey.storeEntitlement({
+    credentialId: cred.credentialId, siteId: r.site.id, publicKey: cred.publicKey,
+    counter: cred.counter, transports: cred.transports, minCents: payload.cents,
+  });
+  res.json({ ok: true });
+});
+
+export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ src/server.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -41,4 +41,5 @@
 import feedRoutes from './routes/feed.js';
 import postsRoutes from './routes/posts.js';
+import paidRoutes from './routes/paid.js';
 import langRoutes from './routes/lang.js';
 import adminUpdatesRoutes from './routes/admin-updates.js';
@@ -400,4 +401,5 @@
 app.use('/', changelogRoutes); // /changelog publieke release-/wijzigingen-pagina
 app.use('/', langRoutes); // /lang/:code — interface-taal kiezen (vóór de catch-all)
+app.use('/paid', paidRoutes);   // paid-posts patron/passkey flow (before the /:slug catch-all)
 app.use('/', postsRoutes);
 
Index: src/services/PaidPatreonService.js
===================================================================
--- src/services/PaidPatreonService.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ src/services/PaidPatreonService.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -124,4 +124,45 @@
 }
 
+// Pure: pick the membership for the owner's campaign out of a Patreon
+// identity?include=memberships.campaign response (JSON:API). Returns
+// { status, cents } or null.
+export function pickCampaignMembership(identity, campaignId) {
+  const inc = (identity && identity.included) || [];
+  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 };
+  }
+  return null;
+}
+
+// Exchange a patron's auth code and read their membership of the owner's
+// campaign. Returns { status, cents } or null. The patron token is used once
+// and discarded here: nothing identifying is stored (design decision).
+export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
+  const c = getOwnerConfig(siteId);
+  if (!c || !c.clientId || !c.clientSecret || !c.campaignId) return null;
+  const tokenRes = await fetchImpl(TOKEN_URL, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({
+      grant_type: 'authorization_code', code,
+      client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
+    }).toString(),
+  });
+  if (!tokenRes.ok) return null;
+  const tok = await tokenRes.json();
+  if (!tok || !tok.access_token) return null;
+  const url = 'https://www.patreon.com/api/oauth2/v2/identity'
+    + '?include=memberships.campaign'
+    + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
+  const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
+  if (!idRes.ok) return null;
+  const identity = await idRes.json();
+  return pickCampaignMembership(identity, c.campaignId);   // token goes out of scope, discarded
+}
+
 function safeDecrypt(blob) {
   try { return decrypt(blob); } catch { return null; }
@@ -131,3 +172,4 @@
   getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
   defaultMinCents, needsRefresh, refreshCreatorToken, creatorAccessToken,
+  pickCampaignMembership, verifyPatron,
 };
Index: src/services/PasskeyService.js
===================================================================
--- src/services/PasskeyService.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/services/PasskeyService.js	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,92 @@
+// Paid posts (klonkt-demo-aki) slice 3: passkey registration + verification for
+// pseudonymous entitlements. Uses @simplewebauthn/server. Cookie-less: the
+// challenge is not kept in a session but travels inside a signed blob
+// (CryptoBox.signBlob) that the client returns, so there is nothing to store
+// between the two requests. An entitlement is {passkey, site, cents, expiry}
+// with NO patron identity.
+import crypto from 'crypto';
+import db from '../config/database.js';
+import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
+
+const DEFAULT_TTL_DAYS = 32;   // aligns with Patreon's monthly cycle; re-link after
+
+// rpID is the site host; origin is the full base URL.
+export function rpFor(base) {
+  let host = ''; try { host = new URL(base).host.split(':')[0]; } catch { /* keep empty */ }
+  return { rpID: host, origin: String(base).replace(/\/+$/, '') };
+}
+
+// Registration options for a fresh, discoverable (usernameless) passkey. The
+// user handle is random: the credential is pseudonymous by design.
+export async function registrationOptions(base, siteSlug) {
+  const { rpID } = rpFor(base);
+  return generateRegistrationOptions({
+    rpName: `Supporter of ${siteSlug}`,
+    rpID,
+    userName: 'supporter',
+    userDisplayName: 'Supporter',
+    userID: crypto.randomBytes(16),
+    attestationType: 'none',
+    authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
+    timeout: 120000,
+  });
+}
+
+// Verify a registration response against the challenge (read from the signed
+// blob by the caller). Returns the credential to store, or null.
+export async function verifyRegistration(base, response, expectedChallenge) {
+  const { rpID, origin } = rpFor(base);
+  let v;
+  try {
+    v = await verifyRegistrationResponse({
+      response,
+      expectedChallenge,
+      expectedOrigin: origin,
+      expectedRPID: rpID,
+      requireUserVerification: false,
+    });
+  } catch { return null; }
+  if (!v || !v.verified || !v.registrationInfo) return null;
+  const cred = v.registrationInfo.credential;
+  return {
+    credentialId: cred.id,                                        // base64url string
+    publicKey: Buffer.from(cred.publicKey).toString('base64url'), // COSE key bytes
+    counter: cred.counter || 0,
+    transports: response.response && response.response.transports ? JSON.stringify(response.response.transports) : null,
+  };
+}
+
+// Store (or refresh) a pseudonymous entitlement for this passkey.
+export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
+  const expiresAt = Math.floor(Date.now() / 1000) + ttlDays * 86400;
+  db.prepare(`INSERT INTO paid_entitlements
+      (credential_id, site_id, public_key, counter, transports, min_cents, expires_at, created_at)
+      VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
+    ON CONFLICT(credential_id) DO UPDATE SET
+      public_key=excluded.public_key, counter=excluded.counter, transports=excluded.transports,
+      min_cents=excluded.min_cents, expires_at=excluded.expires_at`)
+    .run(credentialId, siteId, publicKey, counter || 0, transports || null, Math.max(0, minCents || 0), expiresAt);
+  return expiresAt;
+}
+
+// A valid, unexpired entitlement for this passkey on this site, else null.
+export function getEntitlement(credentialId, siteId) {
+  const row = db.prepare('SELECT * FROM paid_entitlements WHERE credential_id = ? AND site_id = ?').get(credentialId, siteId);
+  if (!row) return null;
+  if ((row.expires_at || 0) < Math.floor(Date.now() / 1000)) return null;
+  return row;
+}
+
+export function deleteEntitlement(credentialId) {
+  return db.prepare('DELETE FROM paid_entitlements WHERE credential_id = ?').run(credentialId).changes > 0;
+}
+
+// Prune expired entitlements (Scheduler, slice 5).
+export function pruneExpired() {
+  return db.prepare('DELETE FROM paid_entitlements WHERE expires_at < ?').run(Math.floor(Date.now() / 1000)).changes;
+}
+
+export default {
+  rpFor, registrationOptions, verifyRegistration, storeEntitlement,
+  getEntitlement, deleteEntitlement, pruneExpired,
+};
Index: src/views/pages/paid-passkey.ejs
===================================================================
--- src/views/pages/paid-passkey.ejs	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/views/pages/paid-passkey.ejs	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,59 @@
+<section class="pk">
+  <div class="pk-card">
+    <div class="pk-ic">🔑</div>
+    <h1 class="pk-h1">Je bent supporter, mooi.</h1>
+    <p class="pk-sub">
+      Maak nu een passkey aan. Die wordt je sleutel voor betaalde posts, zonder
+      account en zonder cookie. We bewaren geen naam of e-mailadres.
+    </p>
+    <button type="button" id="pk-go" class="pk-btn">Maak passkey</button>
+    <p id="pk-status" class="pk-status" hidden></p>
+  </div>
+</section>
+
+<script src="/assets/vendor/simplewebauthn-browser.umd.min.js" nonce="<%= cspNonce %>"></script>
+<script nonce="<%= cspNonce %>">
+(function () {
+  var options = <%- optionsJson %>;
+  var blob = "<%= regBlob %>";
+  var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>";
+  var btn = document.getElementById('pk-go');
+  var status = document.getElementById('pk-status');
+  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
+
+  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
+    btn.disabled = true;
+    say('Passkeys worden niet ondersteund in deze browser.', true);
+    return;
+  }
+  btn.addEventListener('click', function () {
+    btn.disabled = true;
+    say('Volg de vraag van je apparaat…');
+    window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
+      .then(function (response) {
+        return fetch('/paid/register', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ response: response, blob: blob }),
+        });
+      })
+      .then(function (r) { return r.json(); })
+      .then(function (j) {
+        if (j && j.ok) { say('Gelukt. Je passkey is aangemaakt.'); setTimeout(function () { location.href = postUrl; }, 900); }
+        else { btn.disabled = false; say('Aanmaken mislukt (' + ((j && j.error) || 'onbekend') + '). Probeer opnieuw.', true); }
+      })
+      .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? 'Geannuleerd.' : 'Er ging iets mis. Probeer opnieuw.', true); });
+  });
+})();
+</script>
+
+<style>
+  .pk { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
+  .pk-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
+  .pk-ic { font-size: 40px; margin-bottom: 8px; }
+  .pk-h1 { font-size: 24px; margin: 0 0 6px; }
+  .pk-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
+  .pk-btn { padding: 12px 24px; border: none; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font: inherit; font-weight: 600; cursor: pointer; }
+  .pk-btn:disabled { opacity: .6; cursor: default; }
+  .pk-status { margin: 14px 0 0; opacity: .9; }
+  .pk-status.is-err { color: #c0392b; }
+</style>
Index: src/views/pages/paid-result.ejs
===================================================================
--- src/views/pages/paid-result.ejs	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
+++ src/views/pages/paid-result.ejs	(revision 9e9e6f98000b96d6c64d6c0cc5d9eb951ada9804)
@@ -0,0 +1,30 @@
+<section class="pr">
+  <div class="pr-card">
+    <div class="pr-ic">🔒</div>
+    <% if (reason === 'notpatron') { %>
+      <h1 class="pr-h1">Nog geen supporter</h1>
+      <p class="pr-sub">Je bent (nog) geen actieve supporter van deze site op Patreon. Word supporter en probeer het opnieuw.</p>
+    <% } else if (reason === 'tier') { %>
+      <h1 class="pr-h1">Een niveau hoger nodig</h1>
+      <p class="pr-sub">
+        Deze post vraagt vanaf &euro;<%= ((typeof neededCents !== 'undefined' ? neededCents : 0)/100).toFixed(2) %>.
+        Jouw steun is nu &euro;<%= ((typeof haveCents !== 'undefined' ? haveCents : 0)/100).toFixed(2) %>. Verhoog je steun en probeer opnieuw.
+      </p>
+    <% } else { %>
+      <h1 class="pr-h1">Ontgrendelen afgebroken</h1>
+      <p class="pr-sub">Er is niets gekoppeld. Je kunt het opnieuw proberen vanaf de post.</p>
+    <% } %>
+    <% if (typeof postSlug !== 'undefined' && postSlug) { %>
+      <p><a class="pr-btn" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>">Terug naar de post</a></p>
+    <% } %>
+  </div>
+</section>
+
+<style>
+  .pr { max-width: 460px; margin: 0 auto; padding: 56px 18px; text-align: center; }
+  .pr-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
+  .pr-ic { font-size: 38px; margin-bottom: 6px; }
+  .pr-h1 { font-size: 23px; margin: 0 0 8px; }
+  .pr-sub { opacity: .85; line-height: 1.6; margin: 0 0 18px; }
+  .pr-btn { display: inline-block; padding: 11px 20px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; }
+</style>
