How I gave a 12-year-old PHP platform and LimeSurvey one user store without rewriting the login, and what I would do differently today.
The requirement arrived as one sentence: we are going to use Keycloak. Everything after that sentence was mine to design and build, alone, on a platform the business runs on every day.
The platform is a PHP monolith that has been growing since 2014. In 2022 it had 1.6 million lines of PHP in 6,669 files, 10,602 include and require statements and almost no namespaces of its own.
Since then it has moved to PSR-4 autoloading and onto Symfony, with 184 renamed tables and 98 migrated routes. A new UI followed, and the mobile API moved from Apigility to API Platform. I documented that journey in a four-part series: Part 1, Part 2, Part 3 and Part 4.
This article is the side story the series left out. It is about how Keycloak ended up behind a login form I could not break, and how LimeSurvey learned to trust it. It is also about the parts that cost me weeks.
The platform before Keycloak
Before Keycloak, every system kept its own users, and the main login was a plain form checked against the application’s own MySQL table.
The core was a homegrown PHP library with Zend Framework 1 components. A Zend Framework 2 based Apigility API served the mobile apps. Symfony arrived later, first wrapped around the legacy code and then in front of it.
About 100 people work in the office. Field staff take short, irregular assignments, and together with them more than 3,000 people work through the platform at any given time. Many of them are active only now and then, which matters later.
Logging in looked like this:
- Web users typed an email and password into one form, checked against password hashes in the user table.
- The back office had its own login page against the same table.
- The mobile apps logged in through the API, against the same table again.
- LimeSurvey, which we use for surveys, kept its own user accounts.
Why Keycloak, and who chose it
I did not choose Keycloak; the decision came with the task. Nobody asked me to compare it with Auth0, Cognito or an in-house build, so I won’t pretend I did.
The need behind the decision was real. Once LimeSurvey and other tools joined the platform, we needed one central user store instead of an account table per system.
In hindsight the choice fits this platform. Keycloak is open source and self-hosted, so the user store stays on our own infrastructure, like the rest of the platform.
Keeping the login form
I kept the existing login form and changed only what happens after the user presses the button.
The textbook integration sends users to Keycloak’s own login page and back, using the OpenID Connect authorization code flow. For this platform that meant moving every entry point onto Symfony’s security layer and rebuilding the legacy role model there. That is a project of its own with its own test suite, and the form was the front door for thousands of people.
So the form stayed, to save time and to avoid risk. Behind it, the backend now sends the submitted credentials to Keycloak’s token endpoint. Keycloak calls this Direct Access Grants; OAuth calls it the resource owner password credentials grant.
Everything else goes through Keycloak’s Admin REST API: creating users, reading sessions, logging people out. Keycloak answers one question, who this user is. What the user may do is still decided by the application’s own role column.
One realm was enough, because the goal was a user store, not a federation of tenants.

The main app and LimeSurvey meet in a shared session store, while the mobile apps still reach the platform through the legacy API.
Keeping the form came at a cost: current OAuth security guidance, RFC 9700, says the password grant must not be used, and it is the first thing I would change today.
Moving users without touching a hash
No password was migrated in bulk: each user moved to Keycloak the first time they logged in after the switch.
The textbook alternatives both live inside Keycloak. Importing the old hashes needs a custom hash provider unless they already use a format Keycloak supports, such as PBKDF2, or Argon2 on recent versions. Ours were bcrypt over a pre-hashed password, so even an off-the-shelf bcrypt provider would not have verified them without custom code. Federating the legacy database instead needs a User Storage SPI. Both are Java extensions, and migrating on login needs neither.
When a user who has not moved yet logs in, the backend checks the password against the legacy table, exactly as before. If it matches, the backend creates the user in Keycloak with the password that was just typed. Keycloak hashes it with its own algorithm, so the legacy hash format never had to leave the application.

Linked users authenticate against Keycloak; everyone else takes the old path once and is linked on the way.
The user table gained two things: the Keycloak user ID and a flag that says the account has moved. A console command runs at every container start and re-links those IDs, so a rebuilt environment does not orphan anyone.
Users who never log in never move. With field staff who come and go, that is a feature: dormant accounts never reach Keycloak.
One consequence remains. The mobile apps still log in through the legacy API against the old table, so the legacy hash cannot be retired yet, and two password stores have to agree.
LimeSurvey: single sign-on without an SSO protocol
LimeSurvey never speaks OpenID Connect or SAML in this setup; a few small plugins and a shared session store do the work.
LimeSurvey has two audiences. People who fill in surveys never touch Keycloak: a survey opens as a link tagged with the task it belongs to, and a small plugin attaches each completed response to that task. The mobile apps open the same surveys and report each response back through the legacy API.
People who build and evaluate surveys use LimeSurvey’s admin side, and that side needed real single sign-on. Both applications run under the same parent domain and share one Redis session store with the same key prefix. After a successful login, the main app leaves a reference to the Keycloak session where LimeSurvey can find it.
When someone opens the admin area, a plugin hooked into LimeSurvey’s login event reads that reference. It asks Keycloak’s Admin API whether the session is still alive, and only then creates a LimeSurvey session for the matching user. Two extra columns in LimeSurvey’s user table keep the link to the Keycloak identity.
A second plugin repeats the check on every admin request. A third one makes a logout in LimeSurvey end the Keycloak session, which also logs the user out of the main app.
That last part is deliberate. The platform has a single entry point, so logging out has to end the user’s session everywhere, and Keycloak’s admin “log out user” call does that: it ends every Keycloak session the user has, on every device, not just the current one. It is a different model from Google, where signing out of Gmail leaves Drive on your Mac running. If your setup works that way, the Admin API can also end a single session (DELETE /admin/realms/{realm}/sessions/{session}) and leave the user’s other sessions alone.
What broke that I did not expect
Almost nothing broke inside Keycloak itself, but keeping one login consistent in three places caused plenty of trouble.
- Sessions on two sides. A login now lived in the main app’s session, in LimeSurvey’s session and in Keycloak. Most of the bugs I fought were one of the three drifting away from the other two.
- Constant checks. Verifying the session on every LimeSurvey admin request is correct, but it makes Keycloak a dependency of every page. LimeSurvey checks permissions several times per page, so the calls add up.
- Startup order. The application and LimeSurvey cannot do anything useful until Keycloak answers. Their containers now wait for it before they start.
- Admin tokens. Until late 2025 the admin client logged in far more often than it needed to, and every login left a session behind in Keycloak. The fix was to reuse the cached admin token and refresh it shortly before it expires, with a buffer of 20 percent of its lifetime.
- The legacy API. The old API that serves the mobile apps needed to call the new Symfony app. User tokens are the wrong tool for that, so those internal endpoints accept a shared key, verify an HMAC signature of every request and answer only known callers.
The bug that took weeks, and the library it left behind
I no longer remember which bug cost me weeks of line-by-line debugging. My commit log remembers its shape. Messages from those months read like a diary:
After register missing user ID anomaly is solvedAccess token anomaly fixKeycloak login problem for a while is fixedInvalidation of the back-office session after logging in to LimeSurvey is fixedBug fixed of stale Keycloak session login problem
None of them is a Keycloak bug: in each one, two places held different answers about the same login.
What came out of those weeks is a small client library that made working with Keycloak predictable. It has two layers:
- Endpoints. One small class per Keycloak REST call: a URL template and an HTTP method. Calls that need admin rights sit in their own folder, and a proxy in front of them attaches the admin token.
- Transactions. The operations the application thinks in, such as login, logout, register user and update user. Each one combines endpoint calls with the matching local database updates.
Three patterns hold it together. An abstract factory hands out a provider: the family of configuration, endpoints and transactions for one external system. A proxy turns a method name into the endpoint class behind it, and every endpoint and transaction is a strategy behind a common interface that returns the same result object.
// One class per Keycloak endpoint: adding a call means adding a class.
final class ListRoles extends AbstractEndpoint
{
protected const ENDPOINT = '{{host}}/admin/realms/{{realm}}/roles';
protected const METHOD = 'GET';
}
// The provider turns a method name into that class.
public function __call(string $name, array $arguments): ApiResult
{
$class = $this->getNamespace() . '\\Endpoints\\' . ucfirst($name);
if (class_exists($class)) {
return (new $class($arguments))->execute();
}
// The proxy searches the access-level folders and adds the admin token where needed.
return (new EndpointProxy($arguments))->execute($name);
}
// Callers never see URLs, tokens or HTTP.
$roles = KeycloakApi::getInstance()->listRoles([]);
if ($roles->isSuccess()) {
// ...
}
The same library also drives a second, unrelated external API. To me that is the best evidence the seams were in the right place.
Running it
Keycloak runs as its own container next to the application and LimeSurvey. Locally it is part of the same Docker Compose setup as everything else.
LimeSurvey waits for Keycloak at startup, and the application runs a couple of console commands against it. They re-link Keycloak IDs and make sure a break-glass admin account exists. In development another command applies the realm settings, such as one-hour access tokens and two-hour idle sessions. The development environment is pinned to Keycloak 26.0.7.
Orchestration, Keycloak’s database, backups and upgrades are owned by our infrastructure side. From the application’s point of view, it has been running in production without trouble.
What I would do differently
Today I would build this on Symfony’s security layer with a proper role model. That is how I would have liked to build it from the start. The platform was so legacy that some decisions had become too expensive to reverse, so I added lines to the legacy code and moved on.
Now that the codebase runs through Symfony, that design is within reach:
- A Symfony authenticator with the redirect-based authorization code flow and PKCE, so passwords are typed into Keycloak’s own themed login page and never pass through the application.
- Roles held in Keycloak and mapped to Symfony roles from the token, instead of a role column in the user table.
- One password store. The legacy hash survives only because older clients still log in against it, and retiring those clients belongs to the project, not to a later ticket.
- A service account with the client credentials grant for Admin API calls, instead of an admin user and its tokens.
If you are about to put Keycloak in front of a legacy system, expect most of the work to sit outside Keycloak: decide early which system holds the login, and make every other system ask it rather than keep its own copy.
