Index: .env.example
===================================================================
--- .env.example	(revision ae924a2babb279ac76f99438ade75ab50968359c)
+++ .env.example	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
@@ -5,15 +5,14 @@
 MEDIA_PATH=./storage/media
 
-# ── Google login (OAuth2) ──────────────────────────────────────────
-# Inloggen gaat volledig via Google. Maak een OAuth-client aan in de
-# Google Cloud Console (APIs & Services -> Credentials -> OAuth client ID,
-# type "Web application") en vul hieronder in. Zonder deze waarden boot de
-# app gewoon, maar toont de loginpagina "nog niet geconfigureerd".
-#
-# GOOGLE_REDIRECT_URI moet EXACT overeenkomen met een "Authorized redirect
-# URI" in de Google-client, en is per site het eigen domein + /auth/google/callback.
-GOOGLE_CLIENT_ID=
-GOOGLE_CLIENT_SECRET=
-GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback
+# ── Google login via de Klonkt-broker ──────────────────────────────
+# Inloggen gaat via Google, maar gerouteerd door de centrale Klonkt-broker —
+# deze app maakt dus GEEN eigen Google-client aan. Je hoeft hier alleen de
+# broker-URL en het eigen publieke origin te zetten. Zonder deze waarden boot
+# de app gewoon en toont de loginpagina "nog niet geconfigureerd".
+KLONKT_BROKER_URL=https://license.klonkt.com
+# Het eigen publieke origin (scheme + host, geen pad/slash). Bepaalt de callback
+# (<SITE_ORIGIN>/auth/google/callback) en de audience die we eisen. Dit origin
+# moet in de KLONKT_ALLOWED_ORIGINS van de broker staan.
+SITE_ORIGIN=https://example.com
 
 # Het Google-e-mailadres dat owner/admin (god) is op deze instance.
Index: package-lock.json
===================================================================
--- package-lock.json	(revision ae924a2babb279ac76f99438ade75ab50968359c)
+++ package-lock.json	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
@@ -21,4 +21,5 @@
         "helmet": "^7.1.0",
         "htmx.org": "1.9.12",
+        "jose": "^5.10.0",
         "marked": "^11.1.1",
         "multer": "^1.4.5-lts.1",
@@ -1165,4 +1166,13 @@
       }
     },
+    "node_modules/jose": {
+      "version": "5.10.0",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+      "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
     "node_modules/marked": {
       "version": "11.2.0",
Index: package.json
===================================================================
--- package.json	(revision ae924a2babb279ac76f99438ade75ab50968359c)
+++ package.json	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
@@ -23,4 +23,5 @@
     "helmet": "^7.1.0",
     "htmx.org": "1.9.12",
+    "jose": "^5.10.0",
     "marked": "^11.1.1",
     "multer": "^1.4.5-lts.1",
Index: src/config/google.js
===================================================================
--- src/config/google.js	(revision ae924a2babb279ac76f99438ade75ab50968359c)
+++ src/config/google.js	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
@@ -1,56 +1,75 @@
-// Google OAuth2 (per-instance). Raw via de ingebouwde fetch — geen passport-dep.
-// Config via env (per instance, in .env):
-//   GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
-//   GOOGLE_REDIRECT_URI = https://<dit-domein>/auth/google/callback
-//   ADMIN_EMAIL         = de Google-mail die owner/admin (god) is op deze instance
-// Niet geconfigureerd? Dan booten we gewoon door; /auth/google meldt netjes
-// "nog niet geconfigureerd" i.p.v. te crashen.
+// Google-login via de centrale Klonkt-broker (license.klonkt.com).
+//
+// Deze instance praat NOOIT zelf met Google. De broker doet de OAuth-dans met één
+// centrale Google-client en stuurt een kortlevend, gesigneerd identity-token terug;
+// dat verifiëren we offline tegen de broker-pubkey. Zo hoeft geen enkele self-host
+// een eigen Google-client aan te maken.
+//
+// Config via env:
+//   KLONKT_BROKER_URL = https://license.klonkt.com
+//   SITE_ORIGIN       = het eigen publieke origin (bv https://roboburr.com) —
+//                       bepaalt de callback + de audience die we eisen.
+//   ADMIN_EMAIL       = de Google-mail die owner/admin (god) is op deze instance.
 
-const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
-const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
-const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || '';
+import { importSPKI, jwtVerify } from 'jose';
 
-const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
-const TOKEN_URL = 'https://oauth2.googleapis.com/token';
-const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
+const ALG = 'EdDSA';
+const ISSUER = 'klonkt-license';
 
-export function googleConfigured() {
-  return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
+const BROKER_URL = (process.env.KLONKT_BROKER_URL || '').replace(/\/$/, '');
+const SITE_ORIGIN = (process.env.SITE_ORIGIN || '').replace(/\/$/, '');
+
+export function brokerConfigured() {
+  return !!(BROKER_URL && SITE_ORIGIN);
 }
 
-export function authorizeUrl(state) {
-  const p = new URLSearchParams({
-    client_id: CLIENT_ID,
-    redirect_uri: REDIRECT_URI,
-    response_type: 'code',
-    scope: 'openid email profile',
-    state,
-    access_type: 'online',
-    prompt: 'select_account',
-  });
-  return `${AUTH_URL}?${p.toString()}`;
+// Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan).
+export function callbackUrl() {
+  return `${SITE_ORIGIN}/auth/google/callback`;
 }
 
-export async function exchangeCode(code) {
-  const body = new URLSearchParams({
-    code,
-    client_id: CLIENT_ID,
-    client_secret: CLIENT_SECRET,
-    redirect_uri: REDIRECT_URI,
-    grant_type: 'authorization_code',
-  });
-  const r = await fetch(TOKEN_URL, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-    body,
-  });
-  if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status} ${await r.text().catch(() => '')}`);
-  return r.json(); // { access_token, id_token, ... }
+export function brokerStartUrl(istate) {
+  const p = new URLSearchParams({ return: callbackUrl(), istate });
+  return `${BROKER_URL}/auth/google/start?${p.toString()}`;
 }
 
-// Returns { sub, email, email_verified, name, picture }.
-export async function fetchUserinfo(accessToken) {
-  const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
-  if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`);
-  return r.json();
+// Broker-pubkey ophalen + cachen (bij fout NIET permanent cachen).
+let _pubkeyPromise = null;
+function getPublicKey() {
+  if (!_pubkeyPromise) {
+    _pubkeyPromise = (async () => {
+      const r = await fetch(`${BROKER_URL}/pubkey`);
+      if (!r.ok) throw new Error(`broker /pubkey faalde: ${r.status}`);
+      return importSPKI(await r.text(), ALG);
+    })().catch((e) => {
+      _pubkeyPromise = null;
+      throw e;
+    });
+  }
+  return _pubkeyPromise;
 }
+
+// Verifieer het identity-token van de broker. Returnt de payload
+// { typ:'identity', sub, email, name, picture, jti, exp, ... }.
+export async function verifyIdentityToken(token) {
+  const key = await getPublicKey();
+  const { payload } = await jwtVerify(token, key, {
+    issuer: ISSUER,
+    algorithms: [ALG],
+    audience: SITE_ORIGIN, // token moet voor ÓNZE site bedoeld zijn
+  });
+  if (payload.typ !== 'identity') throw new Error('verkeerd tokentype');
+  return payload;
+}
+
+// Kleine in-memory jti-cache tegen replay binnen de (korte) geldigheidsduur.
+// Returnt false als de jti al gebruikt is.
+const _usedJti = new Map(); // jti -> exp (epoch seconds)
+export function consumeJti(jti, expEpoch) {
+  if (!jti) return true; // geen jti = niets te dedupen
+  const now = Math.floor(Date.now() / 1000);
+  for (const [k, e] of _usedJti) if (e < now) _usedJti.delete(k);
+  if (_usedJti.has(jti)) return false;
+  _usedJti.set(jti, expEpoch || now + 600);
+  return true;
+}
Index: src/routes/auth.js
===================================================================
--- src/routes/auth.js	(revision ae924a2babb279ac76f99438ade75ab50968359c)
+++ src/routes/auth.js	(revision 32cc601dd3bba9d9131f32ae282712f54feb838c)
@@ -5,11 +5,12 @@
 import { renderPage } from '../middleware/render.js';
 import { safeNext } from '../middleware/auth.js';
-import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';
+import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js';
 
 const router = express.Router();
 const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
 
-// ==================== LOGIN (alleen Google) ====================
-// Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google.
+// ==================== LOGIN (Google via Klonkt-broker) ====================
+// Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google,
+// gerouteerd door de centrale broker (geen Google-creds op deze instance).
 router.get('/login', (req, res) => {
   const next = safeNext(req.query.next) || '';
@@ -18,5 +19,5 @@
     pageTitle: 'Inloggen',
     bodyClass: 'on-special',
-    googleReady: googleConfigured(),
+    googleReady: brokerConfigured(),
     error: req.query.error || null,
     next,
@@ -24,14 +25,13 @@
 });
 
-// Start de Google OAuth-flow.
+// Start de login: random state in de sessie (CSRF), dan door naar de broker.
 router.get('/google', (req, res) => {
-  if (!googleConfigured()) {
+  if (!brokerConfigured()) {
     return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.'));
   }
-  const next = safeNext(req.query.next) || '';
-  const state = crypto.randomBytes(16).toString('hex');
-  req.session.oauthState = state;
-  req.session.oauthNext = next;
-  res.redirect(authorizeUrl(state));
+  const istate = crypto.randomBytes(16).toString('hex');
+  req.session.loginState = istate;
+  req.session.loginNext = safeNext(req.query.next) || '';
+  res.redirect(brokerStartUrl(istate));
 });
 
@@ -48,23 +48,33 @@
 }
 
-// Google callback: wissel code in, haal profiel, vind-of-maak user (op e-mail),
-// zet de sessie. ADMIN_EMAIL bepaalt wie owner/admin (god) is.
+// Callback van de broker: ?klonkt_id_token=&klonkt_state=(&klonkt_login_error=).
+// We checken state (CSRF), verifiëren het token (sig + audience + replay), en
+// vinden-of-maken de user op e-mail. ADMIN_EMAIL bepaalt wie owner/admin (god) is.
 router.get('/google/callback', async (req, res) => {
   const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg));
   try {
-    const { code, state } = req.query;
-    if (!code || !state || state !== req.session.oauthState) {
-      return fail('Login afgebroken of ongeldige sessie.');
+    const { klonkt_id_token: idToken, klonkt_state: state, klonkt_login_error: loginError } = req.query;
+
+    // State-check (CSRF) eerst — bind de callback aan de sessie die login startte.
+    const expected = req.session.loginState;
+    const next = safeNext(req.session.loginNext) || '';
+    delete req.session.loginState;
+    delete req.session.loginNext;
+    if (!expected || state !== expected) return fail('Login afgebroken of ongeldige sessie.');
+
+    if (loginError === 'unverified_email') return fail('Geen geverifieerd Google-e-mailadres.');
+    if (!idToken) return fail('Geen logintoken ontvangen.');
+
+    let payload;
+    try {
+      payload = await verifyIdentityToken(String(idToken));
+    } catch (e) {
+      console.error('[auth/google/callback] tokenverificatie:', e.message);
+      return fail('Logintoken ongeldig of verlopen.');
     }
-    const next = safeNext(req.session.oauthNext) || '';
-    delete req.session.oauthState;
-    delete req.session.oauthNext;
+    if (!consumeJti(payload.jti, payload.exp)) return fail('Logintoken al gebruikt.');
 
-    const tok = await exchangeCode(String(code));
-    const info = await fetchUserinfo(tok.access_token);
-    const email = (info.email || '').trim().toLowerCase();
-    if (!email || info.email_verified === false) {
-      return fail('Geen geverifieerd Google-e-mailadres ontvangen.');
-    }
+    const email = (payload.email || '').trim().toLowerCase();
+    if (!email) return fail('Geen e-mailadres in logintoken.');
 
     const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL;
@@ -73,13 +83,16 @@
     if (!user) {
       const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
-      const role = (isAdmin || userCount === 0) ? 'god' : 'member';
+      // god = de ingestelde ADMIN_EMAIL. De "eerste user wordt god"-bootstrap geldt
+      // ALLEEN als er helemaal geen ADMIN_EMAIL is ingesteld — anders zou een vreemde
+      // die toevallig als eerste inlogt op een verse install eigenaar worden.
+      const role = isAdmin ? 'god' : (!ADMIN_EMAIL && userCount === 0 ? 'god' : 'member');
       const userId = uuid();
-      const username = uniqueUsername(info.name || email.split('@')[0]);
-      // password_hash is NOT NULL in het schema; we zetten een onbruikbare
-      // sentinel (er is geen wachtwoord-login meer).
+      const username = uniqueUsername(payload.name || email.split('@')[0]);
+      // password_hash is NOT NULL in het schema; er is geen wachtwoord-login meer,
+      // dus we zetten een onbruikbare sentinel.
       db.prepare(`
         INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)
         VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?)
-      `).run(userId, username, info.email || email, role, info.picture || null, info.sub || null);
+      `).run(userId, username, payload.email || email, role, payload.picture || null, payload.sub || null);
 
       // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar
@@ -95,4 +108,10 @@
       user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
     } else {
+      // Identiteits-integriteit: als deze user al aan een ander Google-account
+      // gekoppeld is (google_sub mismatch), weiger — voorkomt overname als een
+      // geverifieerd e-mailadres ooit naar een andere Google-sub verhuist.
+      if (user.google_sub && payload.sub && user.google_sub !== payload.sub) {
+        return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.');
+      }
       // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die
       // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is.
@@ -103,5 +122,5 @@
             updated_at = CURRENT_TIMESTAMP
         WHERE id = ?
-      `).run(info.sub || null, info.picture || null, user.id);
+      `).run(payload.sub || null, payload.picture || null, user.id);
       if (isAdmin && user.role !== 'god') {
         db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id);
