Symfony BFF for Keycloak SSO across self-hosted Appsmith apps
For a description of the implemented concept see https://auth0.com/blog/the-backend-for-frontend-pattern-bff/
Requirements
- PHP >= 8.1, Composer
- A Keycloak realm you can register a confidential client in
Configuration
composer install
cp .env .env.local # keep .env as the checked-in template, override real values in .env.local
Environment variables (.env.local)
| Variable | Purpose |
|---|---|
APP_SECRET |
Random string (openssl rand -hex 32) |
KEYCLOAK_BASE_URL |
e.g. https://keycloak.corp.internal |
KEYCLOAK_REALM |
Realm name |
KEYCLOAK_CLIENT_ID |
Confidential client id (see below) |
KEYCLOAK_CLIENT_SECRET |
That client's secret |
KEYCLOAK_REDIRECT_URI |
https://<bff-host>/auth/callback |
KEYCLOAK_POST_LOGOUT_REDIRECT |
Where Keycloak sends the browser after end_session |
BACKEND_BASE_URL |
Default backend the proxy forwards to |
BACKEND_<KEY>_URL |
Optional additional backends (see "Backends" below) |
APP_<KEY>_LOGIN_URL |
One per Appsmith app (see "Apps" below) |
Keycloak client
Register one confidential client for the BFF (Appsmith apps never get their own):
- Standard flow (Authorization Code) + PKCE enabled
- Redirect URI =
KEYCLOAK_REDIRECT_URI
Apps (config/services.yaml → appsmith.apps)
One entry per Appsmith app: its login-return URL, the Keycloak client role required to enter it, and the prefix marking that app's permission roles.
appsmith.apps:
crm:
url: '%env(APP_CRM_LOGIN_URL)%'
role_prefix: 'app-crm-'
access_role: 'app-crm-access'
helpdesk:
url: '%env(APP_HELPDESK_LOGIN_URL)%'
role_prefix: 'app-helpdesk-'
access_role: 'app-helpdesk-access'
Add the matching APP_<KEY>_LOGIN_URL in .env.local. No code changes needed.
Keep prefixes non-overlapping — with app-crm- and app-crm-admin- as two apps, the first would swallow the second's roles.
Roles and permissions
All roles live on the single BFF client. Two kinds, distinguished only by name:
- Access role (
access_role) — the gate. Without it, login ends in?error=access_denied. - Permission roles — every other role sharing the app's
role_prefix. The prefix is stripped before the app sees them, soapp-crm-writeis reported aswrite. Adding a permission is a Keycloak role creation; nothing changes here or in the code.
Setup in Keycloak:
- On the BFF client → Roles → create
app-crm-accessplus one role per permission (app-crm-view,app-crm-write, …). - Create a group per app (or per role bundle, e.g.
crm-editors), assign roles under the group's Role mapping. - Put users in whichever groups they need.
- Client → Client scopes →
<client>-dedicated→ Mappers → confirm a "client roles" mapper exists with Add to access token enabled (default for new clients).
Where this is enforced: the access role is checked once, in AuthController::callback, against resource_access.<client_id>.roles on the access token right after login. The /api/* proxy does not re-check anything per request.
Permissions are not enforced by the BFF at all. /auth/me reports them so an app can hide or disable widgets, but anyone holding a sid can call the API directly. The backend is the authorization boundary and must check the roles claim on the access token the proxy injects.
Roles are re-read from Keycloak whenever the BFF refreshes the access token (roughly once per token lifetime), so granting or revoking a permission takes effect without a re-login. The access role is deliberately not re-checked there — revoking it takes effect at the next login, or immediately if you kill the Keycloak session.
Backends (config/services.yaml → backends)
One default entry plus any number of additional named backends:
backends:
default:
url: '%env(BACKEND_BASE_URL)%'
crm_api:
url: '%env(BACKEND_CRM_API_URL)%'
Add the matching BACKEND_<KEY>_URL in .env.local. A proxied request uses default unless it sends an X-Backend: <key> header; unknown keys get 400 {"error":"unknown backend"}. Any app may use any backend key.
Running
php -S 127.0.0.1:8000 -t public
# or: symfony server:start
Production: point your webserver at public/ as document root, APP_ENV=prod, serve over TLS (the app sets cookie_secure: true).
Sessions are stored via Symfony's filesystem cache by default (config/packages/cache.yaml) — fine for one instance. For multiple instances behind a load balancer, switch the bff.session_cache pool to cache.adapter.redis and add REDIS_DSN.
Appsmith app integration
Create a shared module with this js:
export default {
isLoggedIn() {
return !!appsmith.store.sid;
},
login(appKey) {
navigateTo(`https://bff.ep-reisen.net/auth/login?app=${appKey}`, {}, 'SAME_WINDOW');
},
async checkReturningFromLogin(loginPageName, protectedPageName) {
const sid = appsmith.URL.queryParams.sid;
const error = appsmith.URL.queryParams.error;
if (error === 'access_denied') {
showAlert('You do not have access to this app.', 'error');
navigateTo(loginPageName, {}, 'SAME_WINDOW');
return;
}
if (sid) {
await storeValue('sid', sid);
navigateTo(protectedPageName, {}, 'SAME_WINDOW');
}
if (appsmith.store.sid && !appsmith.store.user) {
const profile = await Bff_Me.run();
await storeValue('user', profile);
}
},
guardPage(loginPageName) {
if (!this.isLoggedIn()) {
navigateTo(loginPageName, {}, 'SAME_WINDOW');
}
},
async logout() {
const res = await Bff_Logout.run();
await storeValue('sid', null);
await storeValue('user', null);
navigateTo(res.logout_url, {}, 'SAME_WINDOW');
}
}
On the login page add this js to be executed onLoad:
export default {
onLoad() {
return Bff.checkReturningFromLogin('Login', 'ProtectedPage');
}
}
On the protected page add this js to be execute onLoad:
export default {
onLoad() {
return Bff.guardPage('Login');
}
}
Login state can be used to toggle visibility of UI elements in Appsmith with
// No sid? Not logged-in
{{ !appsmith.store.sid }}
// Sid? Logged-in
{{ !!appsmith.store.sid }}
and user info is accessible via
// Full user object
{{ appsmith.store.user }}
API queries — every protected query sets header Authorization: Bearer {{appsmith.store.sid}}, targeting https://<bff-host>/api/<path>. Add X-Backend: <key> to route a specific query to a non-default backend. Add a 401 handler (onError) on each:
storeValue('sid', null);
navigateTo('LoginPage', {}, 'REPLACE');
User profile and permissions — one query Bff_Me: GET /auth/me?app=crm, header Authorization: Bearer {{appsmith.store.sid}}. The app key is required (hardcode the app's own key, same value it passes to Bff.login); one session is shared across every app the user has open, so the BFF needs to know who's asking to scope the permissions. Returns:
{
"email": "[email protected]",
"email_verified": true,
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"preferred_username": "jdoe",
"app": "crm",
"permissions": ["access", "view", "write"]
}
Reference via {{appsmith.store.user.email}} etc. Gate UI on permissions with
{{ appsmith.store.user.permissions.includes('write') }}
This is client-visible display data, not an authorization signal — the backend must still authorize off the real access token the BFF injects.
Known limitation
Logout is immediate at the BFF but not push-propagated to other open app tabs — they find out on their next API call (401). For centrally-triggered logout (e.g. admin-disabled user), add OIDC back-channel logout separately.