Auth migrations are the dental surgery of backend work. Nobody wants one, you cannot skip it once it is needed, and the patient is awake the whole time. Early this year I moved Farmako's authentication from SuperTokens to Keycloak while the app kept serving orders. No maintenance window. People were checking out mid-migration and never knew.
Why move
SuperTokens had been fine for a single app with phone-OTP login. But we had grown into a small fleet: customer app, POS, kiosk, internal tools, a referral site. Each one did its own session handling against the same SuperTokens core, and every new product meant re-answering the same three questions about session storage, token validation across origins, and refresh rotation. I was tired of answering them.
Keycloak is heavy and its admin UI was clearly designed to win enterprise procurement demos, not to be pleasant. I will say that plainly. But realms, clients, and OIDC-standard token exchange map exactly onto the problem we had. One identity, many products, one login. That fit was worth the weight.
The SPI
Keycloak's phone-OTP support out of the box is TOTP only (authenticator app codes). We needed SMS OTP as the primary method, because that is what a pharmacy customer base actually uses. So I wrote a custom Service Provider Interface in Java.
The SPI implements Authenticator and AuthenticatorFactory. The flow:
@Override
public void authenticate(AuthenticationFlowContext context) {
String phone = context.getHttpRequest()
.getDecodedFormParameters()
.getFirst("phone");
// Generate OTP
String otp = String.format("%06d", secureRandom.nextInt(1_000_000));
String hash = BCrypt.hashpw(otp, BCrypt.gensalt(10));
// Store in auth session with TTL
context.getAuthenticationSession()
.setAuthNote("otp_hash", hash);
context.getAuthenticationSession()
.setAuthNote("otp_expiry",
String.valueOf(System.currentTimeMillis() + 300_000)); // 5 min
// Send via SMS gateway
smsGateway.send(phone, "Your code: " + otp);
context.challenge(
context.form()
.setAttribute("phone", phone)
.createForm("otp-verify.ftl")
);
}
On the verify step:
@Override
public void action(AuthenticationFlowContext context) {
String submitted = context.getHttpRequest()
.getDecodedFormParameters()
.getFirst("otp");
String storedHash = context.getAuthenticationSession()
.getAuthNote("otp_hash");
long expiry = Long.parseLong(
context.getAuthenticationSession().getAuthNote("otp_expiry"));
if (System.currentTimeMillis() > expiry) {
context.failureChallenge(AuthenticationFlowError.EXPIRED_CODE, ...);
return;
}
if (!BCrypt.checkpw(submitted, storedHash)) {
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, ...);
return;
}
// Look up or create user by phone
String phone = context.getAuthenticationSession().getAuthNote("phone");
UserModel user = findOrCreateUser(context, phone);
context.setUser(user);
context.success();
}
findOrCreateUser is the migration bridge, and it is the cleverest part of the whole move. It first queries Keycloak's user store by the phone attribute. If nobody is there, it queries the old Postgres user table by phone number. If it finds a match, it creates the Keycloak user with the same UUID via UserModel.setId(), copies the relevant attributes across, and returns it. Users get adopted into Keycloak one login at a time, keeping their old IDs, so every downstream service that references a user ID keeps working. No big-bang user export. No ID remapping. People just logged in as normal and quietly moved over.
The SPI JAR builds with Maven and mounts into the Keycloak container through an init container that drops it in /opt/keycloak/providers/. On startup Keycloak scans that directory and registers what it finds.
Frontend cutover
Each product app swapped the SuperTokens SDK for oidc-client-ts, a standard OIDC client library. Per app, the change was roughly this:
// Before: SuperTokens
import SuperTokens from 'supertokens-web-js';
SuperTokens.init({ apiDomain: 'https://api.example.com', apiBasePath: '/auth' });
// After: OIDC
import { UserManager } from 'oidc-client-ts';
const mgr = new UserManager({
authority: 'https://auth.example.com/realms/main',
client_id: 'customer-app',
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile phone',
});
The backend dropped SuperTokens' session verification middleware, which checked a proprietary session format, and moved to standard JWT validation against Keycloak's JWKS endpoint:
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/realms/main/protocol/openid-connect/certs',
cache: true,
rateLimit: true,
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
callback(null, key.getPublicKey());
});
}
// In middleware:
jwt.verify(token, getKey, { issuer: 'https://auth.example.com/realms/main' });
Moving to standard JWT validation is the quiet win here. Any service in any language can verify a token against a public JWKS endpoint. No SDK, no shared session store, no proprietary format to reimplement per app. That is the thing I wish we had started with.
The logout bug
There is a closed PR in our repo titled "fix: logout" that stands in for a genuinely confusing week of my life. During the transition both session systems were live at once. SuperTokens sessions sat in httpOnly cookies scoped to our domain. Keycloak sessions sat in Keycloak's own cookies plus an OIDC id_token. Signing out of one did not sign you out of the other.
So users landed in a half-logged-in state. The frontend thought they were authenticated because the Keycloak token was valid, but the backend rejected their requests because the SuperTokens session had expired and some middleware was still checking for it. It looked like the app was randomly logging people out, which is about the worst bug to have in a checkout flow.
The fix was making logout explicitly kill both:
async function logout() {
// 1. Keycloak logout
await oidcManager.signoutRedirect({
id_token_hint: user.id_token,
post_logout_redirect_uri: window.location.origin,
});
// 2. SuperTokens session revoke (during transition only)
await fetch('/auth/signout', { method: 'POST', credentials: 'include' });
// 3. Clear all cookies for good measure
document.cookie.split(';').forEach(c => {
document.cookie = c.trim().split('=')[0] +
'=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/';
});
}
That dual-kill ran for about three weeks, until the last client was fully migrated. Then I deleted the SuperTokens code path and the supertokens-web-js dependency outright. Ripping that block out was the most satisfying diff of the quarter.
By March, SuperTokens was gone. The SPI now handles about 15k logins per day, and multi-product SSO does what it promised: log into the customer app and you are already authenticated on the kiosk and the POS. The patient, to stretch the metaphor one last time, walked out of surgery and never realized there had been an operation.