Debug School

rakesh kumar
rakesh kumar

Posted on

How OTP Sending and Verification Works: A Secure Step-by-Step Guide (Keycloak Custom SPI)

Below is a complete, secure Keycloak custom SPI approach where:

✅ OTP is generated in Java (Keycloak)

✅ OTP is stored in AuthNotes (like SharedPreferences concept)

✅ Laravel is used only to send email

✅ Verify does NOT call Laravel (verification happens fully in Java)

Step 1: Decide where OTP lives (Keycloak AuthNotes)

We will store these values in Keycloak AuthenticationSessionModel:

NOTE_EMAIL_OTP_HASH → hash of OTP (secure)

NOTE_EMAIL_OTP_SENT_AT → time when OTP was generated

NOTE_EMAIL_OTP_TRIES → failed attempt counter

NOTE_EMAIL_OTP_SENT → flag (1/empty)

NOTE_EMAIL_VERIFIED → flag (1/empty)

NOTE_EMAIL_VALUE → email
Enter fullscreen mode Exit fullscreen mode

Step 2: Update Laravel API to “send only” (no verify)

✅ Laravel should not generate OTP. It should accept OTP from Keycloak and email it.

public function keyclock_sendemailotp(Request $request)
{
    Log::info('Request data keyclock_sendemailotp:', $request->all());

    $gate = $this->utilityController->emailOtpSendGate($request, 5);
    if (!$gate['ok']) return $gate['response'];

    $request->validate([
        'email' => 'required|email',
        'otp'   => 'required|digits:6',
    ]);

    $getdata = User::where('email', $request->email)->first();
    if ($getdata) {
        return response()->json(['success' => false, 'message' => 'This email is already exist']);
    }

    $email = $request->email;
    $otp   = $request->otp;
    $name  = explode('@', $email)[0];

    Mail::to($email)->send(new OtpMail($otp, $name, $email));

    // ✅ Never return OTP
    return response()->json(['success' => true, 'message' => 'OTP sent to your email']);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Update your Java sender to send OTP to Laravel

LaravelEmailOtpSender.java
package in.motoshare.auth.phoneotp;

import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;

public class LaravelEmailOtpSender implements EmailOtpSender {

    private final String baseUrl;

    public LaravelEmailOtpSender(String baseUrl) {
        this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
    }

    @Override
    public void sendEmailOtp(String email) throws Exception {
        throw new UnsupportedOperationException("Use sendEmailOtp(email, otp)");
    }

    public void sendEmailOtp(String email, String otp) throws Exception {
        URL url = new URL(baseUrl + "/api/keyclock_sendemailotp");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();

        conn.setRequestMethod("POST");
        conn.setRequestProperty("Content-Type", "application/json");
        conn.setDoOutput(true);

        String safeEmail = email.replace("\"", "");
        String payload = "{\"email\":\"" + safeEmail + "\",\"otp\":\"" + otp + "\"}";

        try (OutputStream os = conn.getOutputStream()) {
            os.write(payload.getBytes(StandardCharsets.UTF_8));
        }

        int status = conn.getResponseCode();
        if (status != 200 && status != 201) {
            throw new RuntimeException("Email OTP send failed. Status=" + status);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Add secure helpers in your Authenticator (OTP + hashing)

Add these inside your authenticator class:

import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;

private static final String NOTE_EMAIL_OTP_HASH    = "EMAIL_OTP_HASH";
private static final String NOTE_EMAIL_OTP_SENT_AT = "EMAIL_OTP_SENT_AT";
private static final String NOTE_EMAIL_OTP_TRIES   = "EMAIL_OTP_TRIES";

private static final int OTP_EXPIRY_SECONDS = 300; // 5 minutes
private static final int OTP_MAX_TRIES = 5;

private String generateOtp6() {
    int n = 100000 + new SecureRandom().nextInt(900000);
    return String.valueOf(n);
}

private String sha256Hex(String input) {
    try {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] bytes = md.digest(input.getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) sb.append(String.format("%02x", b));
        return sb.toString();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

private int safeInt(String v, int def) {
    try { return Integer.parseInt(v); } catch (Exception ignored) { return def; }
}

private long safeLong(String v, long def) {
    try { return Long.parseLong(v); } catch (Exception ignored) { return def; }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: SEND OTP branch (Generate in Java → Store in AuthNotes → Call Laravel only to email)

Replace your current "send" part with this secure version:

if ("send".equalsIgnoreCase(emailAction)) {
    System.out.println("[RouteByHint] SEND branch");

    if (email.isBlank() || !email.contains("@")) {
        Response ch = render(context, countries, userType, false, false, email, "Please enter a valid email address.");
        context.challenge(ch);
        return;
    }

    // ✅ generate OTP in Keycloak
    String otpPlain = generateOtp6();
    String otpHash  = sha256Hex(otpPlain);

    // ✅ store securely in AuthNotes
    s.setAuthNote(NOTE_EMAIL_OTP_HASH, otpHash);
    s.setAuthNote(NOTE_EMAIL_OTP_SENT_AT, String.valueOf(System.currentTimeMillis()));
    s.setAuthNote(NOTE_EMAIL_OTP_TRIES, "0");

    try {
        String baseUrl = getBaseUrl(context);
        System.out.println("[RouteByHint] sending email otp via baseUrl=" + baseUrl);

        LaravelEmailOtpSender sender = new LaravelEmailOtpSender(baseUrl);

        // ✅ Laravel just sends email (no verify)
        sender.sendEmailOtp(email, otpPlain);

        System.out.println("[RouteByHint] SEND SUCCESS");
    } catch (Exception e) {
        System.out.println("[RouteByHint] SEND FAILED: " + e.getMessage());
        e.printStackTrace();

        // clean stored otp notes on failure
        s.removeAuthNote(NOTE_EMAIL_OTP_HASH);
        s.removeAuthNote(NOTE_EMAIL_OTP_SENT_AT);
        s.removeAuthNote(NOTE_EMAIL_OTP_TRIES);

        Response ch = render(context, countries, userType, false, false, email, "Unable to send Email OTP. Please try again.");
        context.challenge(ch);
        return;
    }

    // flags for UI
    s.setAuthNote(NOTE_EMAIL_OTP_SENT, "1");
    s.removeAuthNote(NOTE_EMAIL_VERIFIED);
    s.setAuthNote(NOTE_EMAIL_VALUE, email);

    Response ch = render(context, countries, userType, true, false, email, null);
    context.challenge(ch);
    return;
}
Enter fullscreen mode Exit fullscreen mode

Step 6: VERIFY OTP branch (No Laravel call at all)

Add this "verify" branch in your action():

if ("verify".equalsIgnoreCase(emailAction)) {
    System.out.println("[RouteByHint] VERIFY branch");

    String storedHash = safe(s.getAuthNote(NOTE_EMAIL_OTP_HASH));
    long sentAt = safeLong(safe(s.getAuthNote(NOTE_EMAIL_OTP_SENT_AT)), 0L);
    int tries = safeInt(safe(s.getAuthNote(NOTE_EMAIL_OTP_TRIES)), 0);

    if (storedHash.isBlank() || sentAt == 0L) {
        Response ch = render(context, countries, userType, true, false, email, "Please send OTP first.");
        context.challenge(ch);
        return;
    }

    // expiry check
    long now = System.currentTimeMillis();
    long ageSeconds = (now - sentAt) / 1000L;
    if (ageSeconds > OTP_EXPIRY_SECONDS) {
        System.out.println("[RouteByHint] OTP expired");

        s.removeAuthNote(NOTE_EMAIL_OTP_HASH);
        s.removeAuthNote(NOTE_EMAIL_OTP_SENT_AT);
        s.removeAuthNote(NOTE_EMAIL_OTP_TRIES);
        s.removeAuthNote(NOTE_EMAIL_OTP_SENT);

        Response ch = render(context, countries, userType, false, false, email, "OTP expired. Please send again.");
        context.challenge(ch);
        return;
    }

    // tries check
    if (tries >= OTP_MAX_TRIES) {
        System.out.println("[RouteByHint] Too many attempts");

        Response ch = render(context, countries, userType, false, false, email, "Too many wrong attempts. Please send OTP again.");
        context.challenge(ch);
        return;
    }

    if (otp.isBlank() || otp.length() != 6) {
        s.setAuthNote(NOTE_EMAIL_OTP_TRIES, String.valueOf(tries + 1));
        Response ch = render(context, countries, userType, true, false, email, "Please enter a valid 6-digit OTP.");
        context.challenge(ch);
        return;
    }

    String enteredHash = sha256Hex(otp);

    if (!enteredHash.equalsIgnoreCase(storedHash)) {
        s.setAuthNote(NOTE_EMAIL_OTP_TRIES, String.valueOf(tries + 1));
        Response ch = render(context, countries, userType, true, false, email, "Invalid OTP. Please try again.");
        context.challenge(ch);
        return;
    }

    // ✅ success
    System.out.println("[RouteByHint] OTP verified successfully");

    s.setAuthNote(NOTE_EMAIL_VERIFIED, "1");

    // cleanup otp notes
    s.removeAuthNote(NOTE_EMAIL_OTP_HASH);
    s.removeAuthNote(NOTE_EMAIL_OTP_SENT_AT);
    s.removeAuthNote(NOTE_EMAIL_OTP_TRIES);

    Response ch = render(context, countries, userType, true, true, email, null);
    context.challenge(ch);
    return;
}
Enter fullscreen mode Exit fullscreen mode

Step 7: RESET branch (your existing reset is OK)

Your reset branch is fine; just also clear our new notes:

if ("reset".equalsIgnoreCase(emailAction)) {
    System.out.println("[RouteByHint] RESET branch");

    s.removeAuthNote(NOTE_EMAIL_OTP_SENT);
    s.removeAuthNote(NOTE_EMAIL_VERIFIED);
    s.removeAuthNote(NOTE_EMAIL_VALUE);

    s.removeAuthNote(NOTE_EMAIL_OTP_HASH);
    s.removeAuthNote(NOTE_EMAIL_OTP_SENT_AT);
    s.removeAuthNote(NOTE_EMAIL_OTP_TRIES);

    Response ch = render(context, countries, userType, false, false, "", null);
    context.challenge(ch);
    return;
}
Enter fullscreen mode Exit fullscreen mode

Step 8: What you achieved (final result)

✅ OTP generated in Keycloak
✅ Stored securely (hash + expiry + tries)
✅ Laravel used only to send email
✅ Verify is fully offline (no Laravel verify call)

Top comments (0)