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 in AuthController::callback against resource_access.<client_id>.roles on the access token right after login, and re-checked on every /auth/verify and /auth/me call against the role list held on the session. Without it those two answer 403 {"error":"access_denied"} — this is what stops one app's sid from being a skeleton key to another app's UI, since one session is shared by every app the user opens. The /api/* proxy still does not re-check anything per request (it has no way to know which app is calling).
Permissions are not enforced by the BFF at all. /auth/me and /auth/verify report 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. Revoking the access role likewise bounces the user from the app's guard within one token lifetime — 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.
Logging
Two dedicated Monolog channels write to their own rotating files in var/log, in every environment:
| File | Channel | Kept | Records |
|---|---|---|---|
auth.<env>.log |
auth |
30 days | The login/logout/authorization trail |
api.<env>.log |
api |
14 days | One record per /api/* call |
auth — auth.login.start and auth.login.unknown_app (login redirect), auth.callback.state_mismatch and auth.callback.failed (a failed code exchange or token decode, previously an anonymous 500), auth.login.denied (missing access role), auth.login.success, auth.logout, and the per-call rejections from /auth/verify and /auth/me: auth.session.unauthenticated, auth.session.unknown_app, auth.session.expired, auth.session.access_denied. Server-side token refreshes add auth.session.refreshed, auth.session.missing, auth.session.refresh_failed and auth.token.decode_failed. Successful verify/me calls are deliberately not logged — they run on every page load and would bury the rest.
api — api.request for every completed proxy call (info below status 400, warning from 400 up) with method, path, backend, upstream status and duration_ms, plus api.request.unauthenticated, api.request.session_expired, api.request.unknown_backend and api.request.upstream_unreachable.
Both channels are excluded from the main handler, so records are never duplicated and — importantly in prod, where main is fingers_crossed — never buffered away just because the request succeeded.
Two things to know about the content:
- It contains PII. Records carry
user_id(Keycloaksub),email,preferred_usernameand the user's role list. Treatvar/logaccordingly: it is a personal-data store, not just diagnostics. - It never contains credentials. Sessions appear only as
sid_hash, a 12-character SHA-256 prefix of thesid— enough to follow one session across both files, useless as a bearer token. Access, refresh and ID tokens, request/response bodies, query strings and forwarded headers are never logged.
Appsmith app integration
Queries to set up
Each app needs three queries against the BFF. Create them once per app (skip any that already exist); the shared module below calls them by these exact names, so don't rename them. All three are plain REST API queries — no datasource — and all three send the same auth header:
Authorization: Bearer {{appsmith.store.sid}}
| Name | Request | Called by |
|---|---|---|
Bff_Verify |
GET https://<bff-host>/auth/verify?app=<key> |
Bff.guardPage |
Bff_Me |
GET https://<bff-host>/auth/me?app=<key> |
Bff.checkReturningFromLogin |
Bff_Logout |
POST https://<bff-host>/auth/logout |
Bff.logout |
<key> is the app's own key from appsmith.apps, hardcoded in the query URL — the same value the app passes to Bff.login. One session is shared across every app the user has open, so the BFF can only scope its answer if the caller says which app is asking. Bff_Logout needs no key: it kills the session for all apps.
Bff_Verify — the page guard. Carries no profile data, so it stays cheap enough to run on every page load. Returns
{ "valid": true, "app": "crm", "permissions": ["access", "view", "write"] }
or 401 {"error":"unauthenticated"} / 401 {"error":"session expired"} for a missing, forged or dead sid, 403 {"error":"access_denied"} for a live session whose user lacks crm-access, and 400 {"error":"unknown or missing app key"} for a bad ?app=. guardPage treats every non-2xx the same way — clear the stored session, go to the login page.
Bff_Me — profile plus this app's permissions, from the tokens the BFF already holds (no backend call). Applies the same checks as Bff_Verify, so it can answer 401/403 too. On success:
{
"email": "[email protected]",
"email_verified": true,
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"preferred_username": "jdoe",
"app": "crm",
"permissions": ["access", "view", "write"]
}
Bff_Logout — returns {"logout_url": "…"}. The BFF drops the session immediately; the app must then navigate the browser to that URL, since only the browser can clear Keycloak's own SSO cookie. Bff.logout does both.
The app's own data queries — every protected query targets https://<bff-host>/api/<path> and sets the same Authorization header. Add X-Backend: <key> to route a query to a non-default backend. Give each one an onError handler:
{{ Bff.clearSession().then(() => navigateTo('Login', {}, 'REPLACE')) }}
Shared module
Create a shared module with this js. It stays generic — the app key and the page names it needs are supplied by the caller, so the same code can be dropped into every app unchanged:
export default {
isLoggedIn() {
return !!appsmith.store.sid;
},
login(app) {
if (!app) {
// Almost always a binding that isn't a call — see below.
showAlert('Bff.login was called without an app key.', 'error');
return;
}
navigateTo(
`https://bff.ep-reisen.net/auth/login?app=${encodeURIComponent(app)}`,
{},
'SAME_WINDOW',
);
},
async clearSession() {
await storeValue('sid', null);
await storeValue('user', null);
await storeValue('permissions', null);
},
async checkReturningFromLogin(protectedPage) {
if (appsmith.URL.queryParams.error === 'access_denied') {
await this.clearSession();
showAlert('You do not have access to this app.', 'error');
return;
}
const sid = appsmith.URL.queryParams.sid;
if (sid) {
await storeValue('sid', sid);
}
if (!this.isLoggedIn()) {
return;
}
// Profile and permissions land in the store before the protected
// page renders, so its widgets never read an empty user object.
try {
const me = await Bff_Me.run();
await storeValue('user', me);
await storeValue('permissions', me.permissions);
} catch (e) {
await this.clearSession();
return;
}
if (sid) {
navigateTo(protectedPage, {}, 'SAME_WINDOW');
}
},
async guardPage(loginPage) {
if (!loginPage) {
showAlert('Bff.guardPage was called without a login page name.', 'error');
return;
}
if (!this.isLoggedIn()) {
navigateTo(loginPage, {}, 'SAME_WINDOW');
return;
}
try {
const res = await Bff_Verify.run();
await storeValue('permissions', res.permissions);
} catch (e) {
await this.clearSession();
navigateTo(loginPage, {}, 'SAME_WINDOW');
}
},
async logout() {
const res = await Bff_Logout.run();
await this.clearSession();
navigateTo(res.logout_url, {}, 'SAME_WINDOW');
}
}
No per-page JS objects are needed. Add the module to each page, then open it from the JS tab in the editor: each of its methods is listed there with its own argument fields and a Run on page load toggle. (Not the run behavior modal — the argument values are edited per method on the module itself.) Enable page load on the one method that page needs and fill in its argument — this is where the app's own page names live:
| Page | Function to run on page load | Argument |
|---|---|---|
| Login | checkReturningFromLogin |
protectedPage — the page to land on after a successful login |
| Every protected page | guardPage |
loginPage — the page to bounce to when the session doesn't check out |
Note checkReturningFromLogin takes no login page name — it only ever runs on the login page, so there is nowhere to send the user on failure; it clears the session and stays put. guardPage is the one that needs a page name, since it runs on a protected page and has to send the user away.
Bind the login button's onClick to
{{ Bff.login('app-sandbox') }}
using the same app key the Bff_Me and Bff_Verify queries send as ?app=.
Write that binding by hand in the onClick field. Do not use the event dropdown's "Execute a JS function" option to select Bff.login — that invokes it with no arguments, so the app key arrives undefined, the BFF gets /auth/login?app=undefined and answers 404 Unknown or missing app key. Unlike guardPage and checkReturningFromLogin, login is triggered by a widget event rather than page load, so there is no settings field to put the app key in — it has to be in the binding.
guardPage asks the BFF (via Bff_Verify) whether the stored sid is a live session that may enter this app, and bounces to the login page if not — a forged or expired sid, or one belonging to a user without the app's access role, never gets past it. Two caveats: Appsmith renders the page before the page-load function resolves, so the page shell can flash briefly before the redirect; and this is UI gating only. Anyone holding a real sid can still call /api/* with curl, so the backend remains the authorization boundary.
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 — whatever Bff_Me returned — is accessible via
// Full user object
{{ appsmith.store.user }}
// Individual fields
{{ appsmith.store.user.email }}
Gate UI on permissions with
{{ (appsmith.store.permissions || []).includes('write') }}
appsmith.store.permissions is the one to bind against: checkReturningFromLogin seeds it at login and guardPage refreshes it from Bff_Verify on every protected page load, so a permission granted or revoked in Keycloak shows up without a re-login. (appsmith.store.user.permissions holds the same list as of login time only — bind to the store key, not the nested one.)
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.