Improvements in Page Hint State Management for Keycloakify

Improvements in Page Hint State Management for Keycloakify

Introduction

Currently, I am responsible for maintaining the login and account-related screens based on Keycloak in this project. This time, I identified an issue where the initial setup screen for employee accounts switches to the regular password recovery screen upon refreshing, and I analyzed the cause.

At first, I thought it would be sufficient to just put the value that distinguishes the screens in the URL query parameters, like a typical React SPA. However, following the code, I found that this method alone was not sufficient in Keycloakify. The cause was in the screen transition method of Keycloakify and the round-trip process of Keycloak.

Insufficient screen state management with just the URL

When I first saw the issue, I approached it quite simply. Since I needed to distinguish between the initial setup screen for employee accounts and the regular password recovery screen, I thought I could just attach a value like page_hint=isTemp to the URL and use that to differentiate the screens.

For a typical React SPA, this method is not awkward. Screen transitions are usually handled by react-router, and when the URL path or query parameter changes, it can render the corresponding component. Screen states are often managed using React state or query parameters. However, Keycloakify screens work a bit differently. Keycloak delivers screens like login, password reset, and password change on a pageId basis.

login.ftl
login-reset-password.ftl
login-update-password.ftl

Keycloakify renders the React component that matches this pageId. In other words, the screen is created with React, but the large flow operates within Keycloak's authentication flow. For example, when moving to the password recovery screen, it does not simply navigate through the internal router of React; instead, it performs a full-page transition to the URL provided by Keycloak.

const findPassword = useCallback(() => {
  if (!("loginResetCredentialsUrl" in kcContext.url)) return;
  window.location.href = kcContext.url.loginResetCredentialsUrl;
}, [kcContext.url]);

In this method, since the whole page reloads when the screen changes, the values stored only in React state do not persist. States that would naturally be retained in a typical SPA may disappear during the screen transition in Keycloakify.

Keycloak round-trip and state loss

So it would seem that putting values in the URL query would solve the issue, but this is still not enough in Keycloakify. To understand the reason, we first need to clarify what a "round-trip" is.

Here, the round-trip refers to one cycle where a request leaves the browser, goes to the Keycloak server, becomes a new screen, and then comes back. In a typical SPA, although the screen changes, the browser remains on the same page while JavaScript simply replaces the view. In contrast, Keycloak's authentication flow does not work that way. In flows like login or password reset, the frontend does not directly call the API, but instead posts the form to the action URL provided by Keycloak.

<form method="post" action={kcContext.url.loginAction}>

When this form is submitted, the flow generally goes as follows:

  1. The browser leaves the current React screen and sends a POST request to the Keycloak server.

  2. Keycloak processes the authentication flow on the server.

  3. The server generates and returns the next screen (the next pageId) based on the processing results.

  4. The browser receives the response and renders the page from scratch.

This means that the control of the screen transition is with the Keycloak server rather than React, and in this process, the entire page is redrawn. This round trip is what is referred to.

The problem is that the browser URL is also recreated by Keycloak at this time. Therefore, it cannot be guaranteed that the query parameter I attached from the previous screen, such as ?page_hint=isTemp, will remain intact on the next screen. Conversely, the React state will naturally disappear the moment the page is reloaded. Thus, in this project, page_hint was managed on both the URL and sessionStorage.

let page = params.get("page_hint") || sessionStorage.getItem("page_hint");

The reason for reading the same value from two places becomes clear when considering the round trip.

  • While URL queries are good at revealing the current screen state, they can disappear during a round trip.

  • sessionStorage remains even after a refresh or round trip, but if it stays too long, it can affect other flows.

In other words, relying solely on the URL is insufficient, and relying solely on sessionStorage is risky. Therefore, there was a structure that backed up the value in sessionStorage and reflected it back to the URL during rendering.

function syncPageHintToUrl(pageHint: string) {
  const url = new URL(window.location.href);
  url.searchParams.set(&quot;page_hint&quot;, pageHint);
  window.history.replaceState(null, &quot;&quot;, url.toString());
}

A method of distinguishing detail screens with page_hint

In actual service, multiple screens had to be shown using page_hint even under one pageId of Keycloak. The password reset screen had to be distinguished from the general password recovery screen and the initial setup screen for employee accounts. When entering the initial setup for employee accounts, page_hint was saved as isTemp, and then moved to the password reset flow.

onClick={() =&gt; {
  sessionStorage.setItem(&quot;page_hint&quot;, &quot;isTemp&quot;);
  links.findPassword();
}}

In the arriving container, it was determined what screen to show based on this value.

return isTemp
? <ResetVerifyStaff kcContext={kcContext} />
: <FindPassword kcContext={kcContext} />;

The cause of the screen change during a refresh

The problem occurred when refreshing on the initial setup screen for employee accounts. Initially, the employee screen was displayed normally, but after refreshing, it changed to the general password recovery screen. Tracing the cause revealed that the following code was in the same container.

useEffect(() =&gt; {
  sessionStorage.removeItem(&quot;page_hint&quot;);
  const url = new URL(window.location.href);
  url.searchParams.delete(&quot;page_hint&quot;);
  window.history.replaceState(null, &quot;&quot;, url.toString());
}, []);

As soon as the container was mounted, the page_hint distinguishing the employee screen was deleted from both sessionStorage and the URL, making it impossible to determine that it was the employee screen upon refresh.

Confirming the reason for the deletion code

I tried to remove it immediately, but since the authentication and account screens involve multiple flows, I first checked the commit history. This deletion code was a defensive code to prevent errors on the mypage password change screen. If page_hint=isTemp remained in sessionStorage, it could incorrectly determine that it was the employee screen when entering the password change screen through another path later, so it was being deleted after entry.

The essence of the problem has emerged here. page_hint=isTemp was serving two roles simultaneously.

  • Value to maintain the initial setup flow of the employee account

  • Values that should not remain in other common flows

Since one side needed to be maintained while the other needed to be deleted, simply removing the 'delete logic' could impact other flows.

Improvement direction

Upon rechecking the entry path, the main entry point leading to the password reset screen had already explicitly set the page_hint at the starting point.

// 일반 비밀번호 찾기
onClick={() =&gt; {
  sessionStorage.setItem(&quot;page_hint&quot;, &quot;&quot;);
  links.findPassword();
}}
// 직원 계정 최초 설정
onClick={() =&gt; {
  sessionStorage.setItem(&quot;page_hint&quot;, &quot;isTemp&quot;);
  links.findPassword();
}}

In other words, the flow was already determined at the entry point in LoginResetPasswordContainer, and after arriving, the screen state did not remain during refresh because page_hint was necessarily deleted. LoginUpdatePasswordContainer was tied to the My Page password change flow, so the existing defense logic was still necessary. On the other hand, LoginResetPasswordContainer had already explicitly set page_hint at the entry point, so it was concluded that there was no need to delete it necessarily after arriving, and it was modified as follows.

  • Removing the page_hint deletion logic when mounting LoginResetPasswordContainer

  • Maintaining the deletion logic of LoginUpdatePasswordContainer

Although not many modifications were made, it was necessary to safely check Keycloakify's routing structure, round-trip process, and the reasons for existing defense code.

Summary

The issue this time was that the page_hint, which is a screen distinction value, was deleted immediately after mounting, making it impossible to determine that it was an employee screen during refresh. Through the work, I understood that screens based on Keycloakify need to manage states differently than general React SPAs. Since Keycloak leads the screen transitions and there is a round trip where the screen returns after the form POST through the server, it is difficult to stably maintain the screen state with just URL queries or React state.

In addition, when using long-lasting storage like sessionStorage, care must be taken to prevent state from leaking into other flows. When a single page_hint value serves the dual roles of 'maintaining screen' and 'distinguishing flows,' there can be conflicts about when it should be maintained and when it should be deleted.

As a result, this modification involved removing a few lines, but understanding Keycloakify's routing structure and the existing state management intent was crucial for safely removing those few lines.

Lynn

Site footer