Debug School

rakesh kumar
rakesh kumar

Posted on

How to Debug and Build Dynamic Login Redirects in a Custom Keycloak Authenticator

What “Dynamic Redirect” means in Keycloak flows

In Keycloak, you don’t “redirect inside the same authenticator action” if you want to switch flows (OTP → Register).
Instead, you restart the authentication request with a query parameter like:

login_hint=register:renter

login_hint=register:partner

login_hint=otp:whatsapp
Enter fullscreen mode Exit fullscreen mode

Then your RouteByHintAuthenticator.authenticate() reads login_hint and writes notes:

FLOW_MODE=register | otp

USER_TYPE=renter | partner

DEFAULT_CHANNEL=sms | whatsapp
Enter fullscreen mode Exit fullscreen mode

These notes are stored in:

AuthenticationSessionModel (s.setAuthNote(...))
Enter fullscreen mode Exit fullscreen mode

Then later steps (PhoneInput / OTP Gate / Register Gate) decide:

render phone-login page

or skip phone-login and go to register-start

or skip register path and go to otp path

✅ That is proper Keycloak routing design.

2) Key Concepts: authenticate() vs action()
authenticate(context)

Runs when Keycloak reaches that execution step

Used to render a page (context.challenge(form))

action(context)

Runs when the user submits the form (POST)
Enter fullscreen mode Exit fullscreen mode

Used to validate input and move to next step or re-render with errors

Debugging tip: if a click should change route (Register button), it must do a GET to /protocol/openid-connect/auth?... not a POST to loginAction.

That’s why your “Register” must be an (GET), not .

3) Correct Flow (KC Admin) — must be set first

In Authentication → Flows, create a custom Browser flow like:

Browser-Motoshare (Required)

Route By Hint (OTP/Register) — REQUIRED

Methods — REQUIRED (Subflow)

OTP Methods — ALTERNATIVE (Subflow)

OTP Gate (FLOW_MODE=otp) — REQUIRED

Phone Login Enter Phone — REQUIRED

Phone Verify OTP — REQUIRED

Register Methods — ALTERNATIVE (Subflow)

Register Gate (FLOW_MODE=register) — REQUIRED

Register Start — REQUIRED
Enter fullscreen mode Exit fullscreen mode

Then bind it:

✅ Realm → Authentication → Bindings → Browser Flow = Browser-Motoshare
✅ Client (motoshare) overrides must not point to old flow.

If RouteByHint prints don’t show, it means flow binding is wrong.

4) Step-by-step Coding: RouteByHintAuthenticator (Core router)
4.1 Constants (keep same across authenticators)

public static final String NOTE_FLOW_MODE = "FLOW_MODE";
public static final String NOTE_USER_TYPE = "USER_TYPE";
public static final String NOTE_DEFAULT_CHANNEL = "DEFAULT_CHANNEL";
Enter fullscreen mode Exit fullscreen mode

4.2 authenticate() (routing only)

@Override
public void authenticate(AuthenticationFlowContext context) {
    AuthenticationSessionModel s = context.getAuthenticationSession();

    System.out.println("=================================================");
    System.out.println("[RouteByHint] ENTER execId=" + context.getExecution().getId());
    System.out.println("[RouteByHint] uri=" + context.getUriInfo().getRequestUri());
    System.out.println("=================================================");

    String loginHint = readLoginHint(context); // you already wrote correctly

    String mode = "otp";
    String userType = "renter";
    String channel = "whatsapp";

    if (loginHint != null) {
        String lh = loginHint.trim().toLowerCase();
        if (lh.startsWith("register")) mode = "register";
        if (lh.contains(":partner")) userType = "partner";
        if (lh.contains(":renter")) userType = "renter";

        if (lh.startsWith("otp:sms")) channel = "sms";
        if (lh.startsWith("otp:whatsapp")) channel = "whatsapp";
    }

    s.setAuthNote(NOTE_FLOW_MODE, mode);
    s.setAuthNote(NOTE_USER_TYPE, userType);
    s.setAuthNote(NOTE_DEFAULT_CHANNEL, channel);

    System.out.println("[RouteByHint] login_hint=" + loginHint +
            " mode=" + mode +
            " userType=" + userType +
            " channel=" + channel);

    context.success(); // continue to Methods subflow
}
Enter fullscreen mode Exit fullscreen mode

✅ RouteByHint must never render a page, only set notes and success().

5) Step-by-step Coding: PhoneInputAuthenticator (OTP login page)
5.1 Skip logic (VERY IMPORTANT)

@Override
public void authenticate(AuthenticationFlowContext context) {
    AuthenticationSessionModel s = context.getAuthenticationSession();

    if (s.getAuthenticatedUser() != null) {
        context.attempted();
        return;
    }

    String flowMode = safe(s.getAuthNote("FLOW_MODE"));
    if ("register".equalsIgnoreCase(flowMode)) {
        context.attempted(); // skip phone-login when in register mode
        return;
    }

    // fetch apiBase, countries etc...
    Response challenge = context.form()
        .setAttribute("apiBase", apiBase)
        .setAttribute("countries", countries)
        .setAttribute("userType", userType)
        .createForm("phone-login.ftl");

    context.challenge(challenge);
}
Enter fullscreen mode Exit fullscreen mode

5.2 Debugging in FTL requires setAttribute()

Whatever you want to print in FTL must be passed like:

.setAttribute("apiBase", apiBase)
Enter fullscreen mode Exit fullscreen mode

Then in FTL you print with correct case:


${apiBase!"NOT_SET"}
Enter fullscreen mode Exit fullscreen mode

6) Step-by-step Coding: phone-login.ftl (Dynamic redirect “Register”)
6.1 The correct dynamic register URL (NO HARD CODE)

Use Keycloak built-in url.loginUrl:

<#assign baseLoginUrl = url.loginUrl />
<#assign sep = baseLoginUrl?contains("?")?then("&","?") />
<#assign registerUrl = baseLoginUrl + sep + "login_hint=register:" + ut />
Enter fullscreen mode Exit fullscreen mode
<div style="
  background:#111827;
  color:#e5e7eb;
  padding:10px;
  margin-bottom:10px;
  border-radius:6px;
  font-size:12px;
">
  <b>DEBUG – Flags</b><br/><br/>

  apiBase = <b>${apiBase!"NOT_SET"}</b><br/>
  requestBase = <b>${requestBase!"NOT_SET"}</b><br/>
  userType = <b>${userType!"NOT_SET"}</b><br/>
</div>

Enter fullscreen mode Exit fullscreen mode

6.2 Register link must be outside the form

<div class="ms-footer-links" style="margin-top:10px;">
  <a id="registerBtn" class="ms-link" href="${registerUrl}">Register</a>
</div>
Enter fullscreen mode Exit fullscreen mode

=================OR======================================

     </form>

        <div class="ms-footer-links" style="margin-top:10px;">
 <a id="registerBtn"
   class="ms-link"
   href="${apiBase}/realms/motoshare/protocol/openid-connect/auth?client_id=motoshare&redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2Fauth%2Fkeycloak%2Fcallback&response_type=code&scope=openid+email+profile&login_hint=register:renter">
   Register
</a>
</div>
Enter fullscreen mode Exit fullscreen mode

This triggers a fresh GET to /protocol/openid-connect/auth?... → RouteByHint runs.

6.3 Optional force navigation (if overlay/click issues)

<script>
(function(){
  var a = document.getElementById("registerBtn");
  if(!a) return;
  a.addEventListener("click", function(e){
    e.preventDefault();
    window.location.href = a.href;
  }, true);
})();
</script>
Enter fullscreen mode Exit fullscreen mode

7) Debugging Checklist (Most common failures)
A) RouteByHint prints not visible

✅ Browser flow binding wrong OR client override wrong.

B) “Register click opens same page

Usually:

link not navigating (overlay / wrong href)

registerUrl built wrong (& vs ?)

flow still default browser flow

C) FTL debug variable not printing

Because:

you wrote ${apibase} but Java sent "apiBase"

fix: ${apiBase!"NOT_SET"}
Enter fullscreen mode Exit fullscreen mode

D) Internal server error

Usually FreeMarker error due to missing variable. Use safe fallback !"...".

8) Final Working Pattern (Your exact requirement)

✅ Register button should:

not call PhoneInput.action()

restart request with login_hint=register:renter

RouteByHint sets FLOW_MODE=register
Enter fullscreen mode Exit fullscreen mode

OTP Gate/PhoneInput skip

Top comments (0)