1) What this extension solves
Flarum is a SPA, so logging out in one tab or at the IdP can leave other tabs “half-logged-in.” This extension gives you:
- Front-channel logout: Browser goes to Keycloak
/logoutand returns to your forum. - Local logout route: Deletes Flarum server session, auth cookies, and
access_tokensrows so all devices/tabs are out. - Silent probe (
prompt=none): Background check that detects when the Keycloak session is gone and triggers local logout. - SPA cleanup: Clears localStorage/sessionStorage so the UI matches the real auth state.
Result: correct, predictable SSO behavior across tabs and devices.
2) Folder layout
Create a local extension in your Flarum root:

3) Installation
From your Flarum root:
composer config repositories.wizbrand-kc-slo-addon path extensions/kc-slo-addon
composer require wizbrand/kc-slo-addon:dev-main --no-interaction --with-all-dependencies
php flarum extension:enable wizbrand-kc-slo-addon
4) Key settings to line up in Keycloak

In your Keycloak client (e.g., wizbrand-forum):
- Valid Redirect URIs:
https://<your-forum-domain>/kc/probe-return - Web Origins:
Your forum origin (e.g.,https://<your-forum-domain>) - Post Logout Redirect URI (what your code uses):
https://<your-forum-domain>/?logged_out=1
If these don’t match exactly (including scheme and trailing slashes), silent probe or logout return won’t work.
5) How the PHP side is structured (why each part exists)
5.1 Dependency helpers
db()gives you the DB connection (to wipeaccess_tokens).flCfg()gives you Flarum config (cookie profile).forumUrl()returns the forum’s base URL.
Why: makes the rest of the code shorter and consistent.
5.2 Cookie normalization and expirers
cookieConf()reads cookie name/path/domain/secure/samesite from Flarum config.expireCookieHeader()generates completeSet-Cookieheaders to immediately expire cookies (both domainless and domain-specific variants).
Why: some environments require both domain and domainless expires to truly remove cookies.
5.3 Killing tokens in DB
killRememberFromRequest()reads the_remembercookie value and deletes the correspondingaccess_tokensrow. If a user_id is known, it deletes all tokens for that user.
Why: if you only delete the browser cookie, other devices/tabs could stay logged in. Deleting DB rows kills all outstanding tokens.
5.4 Controllers
FrontChannelLogout:- Removes tokens/cookies.
- Clears server session.
- Redirects to Keycloak logout with
post_logout_redirect_uri. - Sets short “bypass” cookies so the next silent probe doesn’t auto-relogin immediately.
LocalLogout:
Hard local logout without contacting Keycloak. Deletes tokens/cookies/server session, then returns to your forum with?logged_out=1.
Why: you need both. Front-channel is the “proper” RP-initiated global logout, while local is a fallback or forced cleanup.
5.5 Silent probe return
ProbeReturn:
Registered as theredirect_uriforprompt=nonechecks.- If Keycloak returns
id_token, SSO is alive → go back to where the user came from. - If Keycloak returns
error=login_required, SSO is gone → go to/slo/local.
- If Keycloak returns
Why: proves the SSO session without any UI.
5.6 Frontend script injection
- Rewires the SPA logout button to go through
/slo/fcso you always end IdP session. - On
?logged_out=1it clears SPA caches and reloads. - Runs a throttled silent probe in the top window to detect expired Keycloak sessions and act accordingly.
Why: Flarum’s SPA state must be cleared or the UI will still think you’re logged in.

6) Step-by-step testing
- Normal logout (front-channel):
Click “Log out” in the forum. You should hit/slo/fc, then Keycloak, then return to your forum with?logged_out=1. The page reloads as a guest. - Local logout route:
Open/slo/localdirectly. You should be logged out with tokens and cookies expired. - Silent probe:
While a forum tab is logged in, log out at Keycloak in another tab. Your forum tab should run the silent probe soon and redirect to/slo/local. - Multi-tab check:
Open two forum tabs. Logout in one; the other should also become guest after the redirect/cleanup. - Database check:
Inspectaccess_tokensbefore and after logout. Rows should be deleted for that user.
7) Common problems and quick fixes
- Still logged in after logout:
- Confirm response headers included
Set-Cookie: <name>=; Expires=...; Max-Age=0. - Verify cookie domain/path match your actual cookie.
- Confirm response headers included
- Silent probe loops or doesn’t return:
- Ensure
/kc/probe-returnis in Keycloak Valid Redirect URIs. - The script throttles to ~8 seconds; reducing this too far can create rapid redirects.
- Check the short-lived
kc_probe_skip=1after logout; it avoids immediate re-login.
- Ensure
- Keycloak refuses post-logout redirect:
- Add that exact URL into Valid Redirect URIs for the client.
- Logout link still the default:
- Some themes/extensions alter the markup. The script hooks
app.session.logout()and also patches anchor links. Inspect DOM and tweak the selector if needed.
- Some themes/extensions alter the markup. The script hooks
All code in one place
Put composer.json at extensions/kc-slo-addon/composer.json and extend.php at extensions/kc-slo-addon/extend.php, then run the three commands under “Installation”.
{
"name": "wizbrand/kc-slo-addon",
"description": "Keycloak RP logout helper (/slo) for Flarum",
"type": "flarum-extension",
"license": "MIT",
"autoload": {
"psr-4": {
"Wizbrand\\KcSloAddon\\": "src/"
}
},
"extra": {
"flarum-extension": {
"title": "KC SLO Addon",
"icon": {
"name": "fas fa-sign-out-alt",
"backgroundColor": "#3a6df0",
"color": "#fff"
}
}
},
"require": {
"flarum/core": "^1.8"
},
"minimum-stability": "dev",
"prefer-stable": true
}
<?php
namespace Wizbrand\KcSloAddon;
use Flarum\Extend;
use Flarum\Foundation\Config;
use Illuminate\Database\ConnectionInterface;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\HtmlResponse;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
/* ---------- helpers ---------- */
function db(): ConnectionInterface { return resolve(ConnectionInterface::class); }
function flCfg(): Config { return resolve(Config::class); }
function forumUrl(): string { return resolve('flarum.forum')->baseUrl(); } // e.g. https://www.wizbrand.com/forum
/** normalize cookie config (no nulls in headers) */
function cookieConf(): array
{
$raw = flCfg()['cookie'] ?? 'flarum';
$name = 'flarum';
$domain = null;
$path = '/';
$secure = false;
$samesite = 'Lax';
if (is_string($raw) && $raw !== '') $name = $raw;
elseif (is_array($raw)) {
if (!empty($raw['name'])) $name = (string)$raw['name'];
if (array_key_exists('domain', $raw) && is_string($raw['domain'])) $domain = $raw['domain'] ?: null;
if (!empty($raw['path'])) $path = (string)$raw['path'];
if (array_key_exists('secure', $raw)) $secure = (bool)$raw['secure'];
if (!empty($raw['samesite'])) {
$ss = ucfirst(strtolower((string)$raw['samesite']));
if (in_array($ss, ['Lax', 'Strict', 'None'], true)) $samesite = $ss;
}
}
return compact('name', 'domain', 'path', 'secure', 'samesite');
}
function expireCookieHeader(string $name, ?string $domain = null, ?string $path = null): string
{
$cc = cookieConf();
$exp = gmdate('D, d M Y H:i:s', time() - 3600) . ' GMT';
$p = [];
$p[] = $name . '=';
$p[] = 'Expires=' . $exp;
$p[] = 'Max-Age=0';
$p[] = 'Path=' . ($path ?: $cc['path'] ?: '/');
if ($domain) $p[] = 'Domain=' . $domain;
elseif (!empty($cc['domain'])) $p[] = 'Domain=' . $cc['domain'];
if (!empty($cc['secure'])) $p[] = 'Secure';
$p[] = 'HttpOnly';
if (!empty($cc['samesite'])) $p[] = 'SameSite=' . $cc['samesite'];
return implode('; ', $p);
}
/** delete remember token (and optionally all tokens for that user) */
function killRememberFromRequest(ServerRequestInterface $req, string $rememberCookieName): void
{
try {
$rem = $req->getCookieParams()[$rememberCookieName] ?? null;
if (!$rem) return;
$row = db()->table('access_tokens')->where('token', $rem)->first();
if ($row && isset($row->user_id)) {
db()->table('access_tokens')->where('user_id', $row->user_id)->delete();
} else {
db()->table('access_tokens')->where('token', $rem)->delete();
}
} catch (\Throwable $e) {
}
}
/* ---------- controllers ---------- */
final class FrontChannelLogout implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
// Keycloak config
$kcBase = 'https://auth.holidaylandmark.com';
$realm = 'wizbrand';
$client = 'wizbrand-forum';
$post = 'https://www.wizbrand.com/forum?logged_out=1'; // add Web Origin + Valid redirect in KC
$base = cookieConf()['name'];
$cSess = $base . '_session';
$cRem = $base . '_remember';
$cCsrf = $base . '_csrf';
// DB cleanup
killRememberFromRequest($request, $cRem);
// server session cleanup (avoid null headers)
$sess = $request->getAttribute('session');
if ($sess) {
try {
$sess->clear();
$sess->migrate(true);
if (method_exists($sess, 'regenerateToken')) $sess->regenerateToken();
else $sess->put('_token', bin2hex(random_bytes(32)));
} catch (\Throwable $e) {
}
}
$kcLogout = $kcBase . '/realms/' . rawurlencode($realm)
. '/protocol/openid-connect/logout'
. '?client_id=' . rawurlencode($client)
. '&post_logout_redirect_uri=' . rawurlencode($post);
$res = new RedirectResponse($kcLogout, 302);
// expire cookies (with domain/path and domainless fallbacks)
foreach ([$cSess, $cRem, $cCsrf] as $n) {
$res = $res->withAddedHeader('Set-Cookie', expireCookieHeader($n));
$res = $res->withAddedHeader('Set-Cookie', expireCookieHeader($n, null, '/'));
}
// prevent immediate probe-triggered re-login
$res = $res->withAddedHeader('Set-Cookie', 'kc_probe_skip=1; Max-Age=20; Path=/; HttpOnly; SameSite=Lax');
$res = $res->withAddedHeader('Set-Cookie', 'fo_forced_guest=1; Max-Age=20; Path=/; SameSite=Lax');
return $res;
}
}
final class LocalLogout implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
$base = cookieConf()['name'];
$cSess = $base . '_session';
$cRem = $base . '_remember';
$cCsrf = $base . '_csrf';
// Also delete tokens for the logged-in actor (if present)
try {
$actor = $request->getAttribute('actor'); // Flarum\User\User or null
if ($actor && isset($actor->id)) {
db()->table('access_tokens')->where('user_id', $actor->id)->delete();
}
} catch (\Throwable $e) {
}
// Still try cookie-based cleanup (covers remember-me)
killRememberFromRequest($request, $cRem);
$res = new RedirectResponse('https://www.wizbrand.com/forum/?logged_out=1', 302);
foreach ([$cSess, $cRem, $cCsrf] as $n) {
$res = $res->withAddedHeader('Set-Cookie', expireCookieHeader($n));
$res = $res->withAddedHeader('Set-Cookie', expireCookieHeader($n, null, '/'));
}
// Block probe and any auto-SSO for a short window
$res = $res->withAddedHeader('Set-Cookie', 'kc_probe_skip=1; Max-Age=20; Path=/; HttpOnly; SameSite=Lax');
$res = $res->withAddedHeader('Set-Cookie', 'fo_forced_guest=1; Max-Age=20; Path=/; SameSite=Lax');
return $res;
}
}
/** KC probe return page: checks hash/query; reads "from" from state (base64url JSON) */
final class ProbeReturn implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
$html = <<<'HTML'
<!doctype html><meta charset="utf-8">
<script>
(function(){
function b64urlDecode(s){
try{
s = s.replace(/-/g,'+').replace(/_/g,'/');
while (s.length % 4) s += '=';
return decodeURIComponent(escape(atob(s)));
}catch(e){ return ''; }
}
var hash = location.hash || '';
var query = location.search || '';
// KC says "no active session"
if (/[#&]error=/.test(hash) || /[?&]error=/.test(query)) {
var forumBase = (window.app && app.forum) ? app.forum.attribute('baseUrl') : 'https://www.wizbrand.com/forum';
location.replace(forumBase + '/slo/local');
return;
}
// id_token present -> session alive, go back to "from"
if (/[#&]id_token=/.test(hash)) {
var stateMatch = hash.match(/(?:^|[&#])state=([^&#]+)/) || query.match(/(?:[?&])state=([^&#]+)/);
var to = 'https://www.wizbrand.com/forum/';
if (stateMatch && stateMatch[1]) {
try {
var st = JSON.parse(b64urlDecode(stateMatch[1]));
if (st && st.from && typeof st.from === 'string') to = st.from;
} catch(e){}
}
location.replace(to);
return;
}
// fallback -> no session
location.replace(window.app.forum.attribute('baseUrl') + '/slo/local');
})();
</script>
HTML;
return new HtmlResponse($html, 200);
}
}
/* ---------- wiring ---------- */
return [
// Inject JS: reroute menu logout, clear SPA after KC logout, and run top-level probe
(new Extend\Frontend('forum'))->content(function ($view) {
$view->foot[] = <<<'HTML'
<script>
(function () {
function hookLogoutMenu() {
try {
const forumBase = window.app && app.forum ? app.forum.attribute('baseUrl') : '';
if (window.app && app.session && typeof app.session.logout === 'function') {
app.session.logout = function () { window.location.href = forumBase + '/slo/fc'; return Promise.resolve(); };
}
var a = document.querySelector('a[data-item="logOut"], a[aria-label="Log Out"]');
if (a) {
a.href = forumBase + '/slo/fc';
a.addEventListener('click', function(e){ e.preventDefault(); window.location.href = forumBase + '/slo/fc'; });
}
} catch (e) {}
}
function clearSpaAfterKcLogout() {
try {
if (location.search.indexOf('logged_out=1') !== -1) {
try { localStorage.removeItem('token'); } catch(e){}
try { localStorage.removeItem('flarum_user_id'); } catch(e){}
try { sessionStorage.clear(); } catch(e){}
try { if (window.app && app.session) { app.session.token = null; app.session.user = null; if (typeof m !== 'undefined' && m.redraw) m.redraw(); } } catch(e){}
var u = new URL(location.href); u.searchParams.delete('logged_out');
history.replaceState({}, '', u.pathname + (u.search ? u.search : '') + u.hash);
setTimeout(function(){ window.location.reload(); }, 20);
}
} catch (e) {}
}
// Top-level probe (only when logged-in user is present and skip flag not set)
function runKcProbeTopLevel() {
try {
if (document.cookie.indexOf('kc_probe_skip=1') !== -1) return;
if (!(window.app && app.session && app.session.user)) return;
// throttle: once every 8s
var now = Date.now();
var last = parseInt(localStorage.getItem('kc_probe_ts') || '0', 10);
if (now - last < 8000) return;
localStorage.setItem('kc_probe_ts', String(now));
var payload = { from: location.href, ts: now };
var state = btoa(unescape(encodeURIComponent(JSON.stringify(payload))))
.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
var redirect = 'https://www.wizbrand.com/forum/kc/probe-return'; // must be in KC Valid Redirect URIs
var auth = 'https://auth.holidaylandmark.com/realms/' + encodeURIComponent('wizbrand') +
'/protocol/openid-connect/auth' +
'?client_id=' + encodeURIComponent('wizbrand-forum') +
'&redirect_uri=' + encodeURIComponent(redirect) +
'&response_type=id_token' +
'&response_mode=fragment' +
'&scope=openid' +
'&prompt=none' +
'&nonce=' + Math.random().toString(36).slice(2) +
'&state=' + state;
if (!/\/kc\/probe-return\b/.test(location.pathname)) {
location.replace(auth);
}
} catch(e){}
}
function boot(){ hookLogoutMenu(); clearSpaAfterKcLogout(); runKcProbeTopLevel(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
})();
</script>
HTML;
}),
(new Extend\Routes('forum'))
->get('/slo/fc', 'kc.slo.front', FrontChannelLogout::class)
->get('/slo/local', 'kc.slo.local', LocalLogout::class)
->get('/kc/probe-return', 'kc.probe', ProbeReturn::class),
];
My Blog on Integrating Flarum with Keycloak:-
How to Integrate Flarum with Keycloak and Enable SSO Login from a Laravel Project (Step-by-Step)