Index: src/routes/account.js
===================================================================
--- src/routes/account.js	(revision 4407c67257b1d061f0376ef1ea63ea597c308e4b)
+++ src/routes/account.js	(revision 2d66d66df4fdd568ae1d3ba78850058c55572590)
@@ -23,4 +23,6 @@
 import { getPrimarySite } from '../middleware/site.js';
 import { renderPage } from '../middleware/render.js';
+import OAuth from '../services/OAuthService.js';
+import { t } from '../services/i18n.js';
 import { requireAuth } from '../middleware/auth.js';
 import { toWebp } from '../services/ImageWebpService.js';
@@ -75,7 +77,17 @@
     // Display fallback: when you have no own account avatar, show your site's photo.
     siteAvatar: editableSite ? editableSite.profile_photo : null,
+    // OAuth apps (C2S) this user has authorized, so they can revoke them here.
+    authorizations: OAuth.listAuthorizations(req.session.user.id),
     success: req.query.success || null,
     error: req.query.error || null,
   });
+});
+
+// ==================== REVOKE AN OAUTH APP AUTHORIZATION ====================
+router.post('/oauth/revoke', requireAuth, (req, res) => {
+  const lang = req.session.lang || (req.session.user && req.session.user.lang) || 'nl';
+  const ok = OAuth.revokeAuthorization(req.session.user.id, req.body.token_hash);
+  const msg = ok ? t(lang, 'acct.oauth_revoked') : t(lang, 'acct.oauth_revoke_none');
+  res.redirect('/account?' + (ok ? 'success' : 'error') + '=' + encodeURIComponent(msg));
 });
 
Index: src/services/OAuthService.js
===================================================================
--- src/services/OAuthService.js	(revision 4407c67257b1d061f0376ef1ea63ea597c308e4b)
+++ src/services/OAuthService.js	(revision 2d66d66df4fdd568ae1d3ba78850058c55572590)
@@ -109,3 +109,30 @@
 }
 
-export default { registerClient, getClient, createCode, exchangeCode, verifyBearer, revokeToken, validRedirectUri };
+// The active authorizations (bearer tokens) a user has granted, with the app
+// name and the site each is scoped to. The bearer itself is never stored, so
+// revocation is keyed on token_hash: safe to render, you cannot derive the
+// token from its hash.
+export function listAuthorizations(userId) {
+  return db.prepare(`
+    SELECT t.token_hash, t.site_slug, t.scope, t.created_at, t.last_used_at, c.client_name
+    FROM oauth_tokens t
+    LEFT JOIN oauth_clients c ON c.client_id = t.client_id
+    WHERE t.user_id = ?
+    ORDER BY t.created_at DESC
+  `).all(String(userId || ''));
+}
+
+// Revoke one authorization, scoped to the owner so a user can only revoke their
+// own tokens. Returns true when a row was removed.
+export function revokeAuthorization(userId, tokenHash) {
+  try {
+    const r = db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ? AND user_id = ?')
+      .run(String(tokenHash || ''), String(userId || ''));
+    return r.changes > 0;
+  } catch { return false; }
+}
+
+export default {
+  registerClient, getClient, createCode, exchangeCode, verifyBearer, revokeToken, validRedirectUri,
+  listAuthorizations, revokeAuthorization,
+};
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 4407c67257b1d061f0376ef1ea63ea597c308e4b)
+++ src/services/i18n.js	(revision 2d66d66df4fdd568ae1d3ba78850058c55572590)
@@ -744,5 +744,5 @@
     'acct.back_home': 'Terug naar home',
     'acct.title': 'Account',
-    'acct.subtitle': 'Profiel en avatar.',
+    'acct.subtitle': 'Profiel en avatar.', 'acct.oauth_apps': 'Verbonden apps', 'acct.oauth_hint': 'Apps die je toegang tot je account hebt gegeven via OAuth. Trek in wat je niet meer vertrouwt of gebruikt.', 'acct.oauth_none': 'Nog geen apps verbonden.', 'acct.oauth_unknown_app': 'Onbekende app', 'acct.oauth_last_used': 'laatst gebruikt', 'acct.oauth_never': 'nooit', 'acct.oauth_revoke': 'Intrekken', 'acct.oauth_revoked': 'App-toegang ingetrokken.', 'acct.oauth_revoke_none': 'Die toegang bestond niet meer.',
     'acct.viewer_mode': 'Kijker-modus',
     'acct.viewer_note': 'Dit is een demo-account. Je kunt alles bekijken, maar niets wijzigen — ook geen foto of bio.',
@@ -1671,5 +1671,5 @@
     'acct.back_home': 'Back to home',
     'acct.title': 'Account',
-    'acct.subtitle': 'Profile and avatar.',
+    'acct.subtitle': 'Profile and avatar.', 'acct.oauth_apps': 'Connected apps', 'acct.oauth_hint': 'Apps you granted access to your account via OAuth. Revoke anything you no longer trust or use.', 'acct.oauth_none': 'No apps connected yet.', 'acct.oauth_unknown_app': 'Unknown app', 'acct.oauth_last_used': 'last used', 'acct.oauth_never': 'never', 'acct.oauth_revoke': 'Revoke', 'acct.oauth_revoked': 'App access revoked.', 'acct.oauth_revoke_none': 'That access no longer existed.',
     'acct.viewer_mode': 'Viewer mode',
     'acct.viewer_note': 'This is a demo account. You can view everything, but change nothing — not even your photo or bio.',
@@ -2598,5 +2598,5 @@
     'acct.back_home': 'Zurück zur Startseite',
     'acct.title': 'Konto',
-    'acct.subtitle': 'Profil und Avatar.',
+    'acct.subtitle': 'Profil und Avatar.', 'acct.oauth_apps': 'Verbundene Apps', 'acct.oauth_hint': 'Apps, denen du \u00fcber OAuth Zugriff auf dein Konto gegeben hast. Widerrufe, was du nicht mehr vertraust oder nutzt.', 'acct.oauth_none': 'Noch keine Apps verbunden.', 'acct.oauth_unknown_app': 'Unbekannte App', 'acct.oauth_last_used': 'zuletzt genutzt', 'acct.oauth_never': 'nie', 'acct.oauth_revoke': 'Widerrufen', 'acct.oauth_revoked': 'App-Zugriff widerrufen.', 'acct.oauth_revoke_none': 'Dieser Zugriff bestand nicht mehr.',
     'acct.viewer_mode': 'Betrachter-Modus',
     'acct.viewer_note': 'Dies ist ein Demo-Konto. Du kannst alles ansehen, aber nichts ändern — auch kein Foto oder keine Bio.',
Index: src/views/pages/account.ejs
===================================================================
--- src/views/pages/account.ejs	(revision 4407c67257b1d061f0376ef1ea63ea597c308e4b)
+++ src/views/pages/account.ejs	(revision 2d66d66df4fdd568ae1d3ba78850058c55572590)
@@ -117,4 +117,28 @@
   <%# ── PASSWORD ──────────────────────────────────────────── %>
   <%# In viewer mode there is no password section (nothing to change). %>
+  <%# ── CONNECTED APPS (OAuth C2S) ─────────────────────────── %>
+  <section class="ax-card">
+    <div class="ax-card-title"><%= t('acct.oauth_apps') %></div>
+    <p class="ax-tagline" style="margin:0 0 .6rem"><%= t('acct.oauth_hint') %></p>
+    <% if (!authorizations || !authorizations.length) { %>
+      <p class="ax-oauth-empty"><%= t('acct.oauth_none') %></p>
+    <% } else { %>
+      <ul class="ax-oauth-list">
+        <% authorizations.forEach(function(a){ %>
+          <li class="ax-oauth-item">
+            <div class="ax-oauth-info">
+              <strong><%= a.client_name || t('acct.oauth_unknown_app') %></strong>
+              <span class="ax-oauth-meta">@<%= a.site_slug %> · <%= a.scope || 'c2s' %> · <%= t('acct.oauth_last_used') %> <%= a.last_used_at ? formatDate(a.last_used_at) : t('acct.oauth_never') %></span>
+            </div>
+            <form action="/account/oauth/revoke" method="post">
+              <input type="hidden" name="token_hash" value="<%= a.token_hash %>">
+              <button type="submit" class="ax-btn ax-btn-danger"><%= t('acct.oauth_revoke') %></button>
+            </form>
+          </li>
+        <% }); %>
+      </ul>
+    <% } %>
+  </section>
+
   <% if (canMutate) { %>
   <% if (hasPassword) { %>
@@ -331,3 +355,13 @@
   .ax-profile-id { flex-direction: column; align-items: flex-start; gap: 0.75rem; text-align: left; }
 }
+
+.ax-oauth-empty { margin: 0; color: var(--ink-soft, #888); }
+.ax-oauth-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
+.ax-oauth-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem .85rem; border-radius: 12px; background: var(--paper-2, rgba(0,0,0,.04)); }
+.ax-oauth-info { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
+.ax-oauth-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ax-oauth-meta { font-size: .8rem; color: var(--ink-soft, #888); }
+@media (max-width: 480px) {
+  .ax-oauth-item { flex-direction: column; align-items: stretch; }
+}
 </style>
