Why your flags become true/false randomly
In Keycloak custom authenticators:
1) authenticate() runs on GET (page render)
You should read session notes and render UI attributes
This is where you show flags like:
emailOtpSent
emailVerified
phoneOtpSent
phoneVerified
phoneChannel
phoneDialCode
phoneE164
2) action() runs on POST (form submit)
This is where you process buttons (send, verify, reset, send_otp, verify_otp, register)
You should update session notes first, then re-read from session, then re-render
✅ Key rule: FTL should never trust form input for state.
Always trust AuthenticationSessionModel notes (s.getAuthNote(...)).
The real reason “phoneChannel” or flags are inconsistent
Because your UI shows variables passed via .setAttribute(...), and those are built from:
sometimes form values (channel/dialCode)
sometimes notes (NOTE_PHONE_CHANNEL / NOTE_DEFAULT_CHANNEL)
sometimes wrong note keys (example: NOTE_PHONE_DIAL vs NOTE_PHONE_DIAL_CODE)
So your “true/false + channel” must have ONE single source of truth:
✅ Session Notes.
Step-by-step coding pattern (ONLY flags + phoneChannel persistence)
Step 1: Define a single source of truth (note keys)
Use these keys consistently everywhere:
private static final String NOTE_EMAIL_OTP_SENT = "EMAIL_OTP_SENT"; // "1" / "0"
private static final String NOTE_EMAIL_VERIFIED = "EMAIL_VERIFIED"; // "1" / "0"
private static final String NOTE_EMAIL_VALUE = "EMAIL_OTP_EMAIL";
private static final String NOTE_PHONE_OTP_SENT = "PHONE_OTP_SENT"; // "1" / "0"
private static final String NOTE_PHONE_VERIFIED = "PHONE_VERIFIED"; // "1" / "0"
private static final String NOTE_PHONE_CHANNEL = "PHONE_CHANNEL"; // "sms"|"whatsapp"
private static final String NOTE_PHONE_DIAL_CODE = "PHONE_DIAL_CODE"; // "+91"
private static final String NOTE_PHONE_RAW = "PHONE_RAW";
private static final String NOTE_PHONE_E164 = "PHONE_E164";
private static final String NOTE_DEFAULT_CHANNEL = "DEFAULT_CHANNEL"; // fallback if PHONE_CHANNEL empty
Important: Don’t mix NOTE_PHONE_DIAL and NOTE_PHONE_DIAL_CODE. Pick one.
In your class, you already have both → that causes mismatch.
Step 2: In authenticate() always read notes and render flags
Pattern:
@Override
public void authenticate(AuthenticationFlowContext context) {
AuthenticationSessionModel s = context.getAuthenticationSession();
boolean emailOtpSent = "1".equals(safe(s.getAuthNote(NOTE_EMAIL_OTP_SENT)));
boolean emailVerified = "1".equals(safe(s.getAuthNote(NOTE_EMAIL_VERIFIED)));
String emailValue = safe(s.getAuthNote(NOTE_EMAIL_VALUE));
boolean phoneOtpSent = "1".equals(safe(s.getAuthNote(NOTE_PHONE_OTP_SENT)));
boolean phoneVerified = "1".equals(safe(s.getAuthNote(NOTE_PHONE_VERIFIED)));
String phoneDialCode = safe(s.getAuthNote(NOTE_PHONE_DIAL_CODE));
String phoneE164 = safe(s.getAuthNote(NOTE_PHONE_E164));
String phoneChannel = safe(s.getAuthNote(NOTE_PHONE_CHANNEL));
if (phoneChannel.isBlank()) {
phoneChannel = safe(s.getAuthNote(NOTE_DEFAULT_CHANNEL)); // fallback
}
Response ch = context.form()
.setAttribute("emailOtpSent", emailOtpSent)
.setAttribute("emailVerified", emailVerified)
.setAttribute("emailValue", emailValue)
.setAttribute("phoneOtpSent", phoneOtpSent)
.setAttribute("phoneVerified", phoneVerified)
.setAttribute("phoneDialCode", phoneDialCode)
.setAttribute("phoneE164", phoneE164)
.setAttribute("phoneChannel", phoneChannel)
.createForm("register-start.ftl");
context.challenge(ch);
}
✅ Now your debug panel will always reflect real state.
Step 3: In action() update notes FIRST, then re-render from notes
Example: user selects WhatsApp + dial code and clicks “Send OTP”
Do:
if ("send_otp".equalsIgnoreCase(phoneAction)) {
// 1) Save the selection FIRST
s.setAuthNote(NOTE_PHONE_CHANNEL, channel); // whatsapp|sms
s.setAuthNote(NOTE_PHONE_DIAL_CODE, dialCode); // +91
s.setAuthNote(NOTE_PHONE_RAW, phoneRaw); // raw input
// 2) When OTP successfully sent:
s.setAuthNote(NOTE_PHONE_OTP_SENT, "1");
s.setAuthNote(NOTE_PHONE_VERIFIED, "0"); // reset verified
s.setAuthNote(NOTE_PHONE_E164, finalPhone); // store computed E164
// 3) Now always re-read from notes (do NOT trust local vars)
boolean phoneOtpSentNow = "1".equals(safe(s.getAuthNote(NOTE_PHONE_OTP_SENT)));
boolean phoneVerifiedNow = "1".equals(safe(s.getAuthNote(NOTE_PHONE_VERIFIED)));
String dialNow = safe(s.getAuthNote(NOTE_PHONE_DIAL_CODE));
String e164Now = safe(s.getAuthNote(NOTE_PHONE_E164));
String chanNow = safe(s.getAuthNote(NOTE_PHONE_CHANNEL));
Response ch = context.form()
.setAttribute("phoneOtpSent", phoneOtpSentNow)
.setAttribute("phoneVerified", phoneVerifiedNow)
.setAttribute("phoneDialCode", dialNow)
.setAttribute("phoneE164", e164Now)
.setAttribute("phoneChannel", chanNow)
.createForm("register-start.ftl");
context.challenge(ch);
return;
}
✅ This guarantees your FTL shows
phoneOtpSent=true and correct
phoneChannel.
Step 4: When verifying OTP, flip only verified flag
if ("verify_otp".equalsIgnoreCase(phoneAction)) {
// ...validate otp...
s.setAuthNote(NOTE_PHONE_VERIFIED, "1");
// Optional: keep NOTE_PHONE_OTP_SENT as "1" (fine)
// Or remove it (also fine), but UI should use phoneVerified to decide.
// Re-render from notes
boolean phoneOtpSentNow = "1".equals(safe(s.getAuthNote(NOTE_PHONE_OTP_SENT)));
boolean phoneVerifiedNow = "1".equals(safe(s.getAuthNote(NOTE_PHONE_VERIFIED)));
String dialNow = safe(s.getAuthNote(NOTE_PHONE_DIAL_CODE));
String e164Now = safe(s.getAuthNote(NOTE_PHONE_E164));
String chanNow = safe(s.getAuthNote(NOTE_PHONE_CHANNEL));
Response ch = context.form()
.setAttribute("phoneOtpSent", phoneOtpSentNow)
.setAttribute("phoneVerified", phoneVerifiedNow)
.setAttribute("phoneDialCode", dialNow)
.setAttribute("phoneE164", e164Now)
.setAttribute("phoneChannel", chanNow)
.createForm("register-start.ftl");
context.challenge(ch);
return;
}
Step 5: FTL debug block (correct, stable)
Your FTL is already right. Just ensure you use the exact case:
phoneOtpSent = <b>${phoneOtpSent?c}</b><br/>
phoneVerified = <b>${phoneVerified?c}</b><br/>
phoneDialCode = <b>${phoneDialCode!""}</b><br/>
phoneE164 = <b>${phoneE164!""}</b><br/>
phoneChannel = <b>${phoneChannel!""}</b><br/>
==================OR==================
<form id="kc-register-start-form" action="${url.loginAction}" method="post" autocomplete="off">
<input type="hidden" id="dial_code" name="dial_code" value="${phoneDialCode!""}" />
<input type="hidden" id="phone_e164" name="phone_e164" value="${phoneE164!""}" />
<label class="ms-rs-label">
<#assign phoneOtpSent = (phoneOtpSent!false) />
<#assign phoneVerified = (phoneVerified!false) />
<#assign phoneRaw = (phoneRaw!"") />
<#assign phoneDialCode = (phoneDialCode!"") />
<#assign phoneChannel = (phoneChannel!"") />
<#if isPartnerResolved>Name of Partner<#else>Name</#if>
</label>
<input class="ms-rs-input" type="text" name="name" value="${nameValue!""}" placeholder="Enter your company or full name" />
<label class="ms-rs-label">E-Mail Address</label>
<#-- server flags -->
<div style="
background:#111827;
color:#e5e7eb;
padding:10px;
margin-bottom:10px;
border-radius:6px;
font-size:12px;
">
<b>DEBUG – Flags</b><br/><br/>
<b>Email</b><br/>
emailOtpSent = <b>${emailOtpSent?c}</b><br/>
emailVerified = <b>${emailVerified?c}</b><br/>
emailValue = <b>${emailValue}</b><br/><br/>
<b>Phone</b><br/>
phoneOtpSent = <b>${phoneOtpSent?c}</b><br/>
phoneVerified = <b>${phoneVerified?c}</b><br/>
phoneDialCode = <b>${phoneDialCode}</b><br/>
phoneE164 = <b>${phoneE164}</b><br/>
phoneChannel = <b>${phoneChannel}</b><br/>
</div>
One critical correction in your current code
In different places you use:
NOTE_PHONE_DIAL
NOTE_PHONE_DIAL_CODE
This will break UI values.
✅ Fix: use only NOTE_PHONE_DIAL_CODE everywhere (recommended).
So replace reads like:
safe(s.getAuthNote(NOTE_PHONE_DIAL))
with:
safe(s.getAuthNote(NOTE_PHONE_DIAL_CODE))
Top comments (0)