IB-SIA-08 Advanced Est. 5 to 6.5 hours
Identity Bytes // Senior IAM Architect Track

Adaptive and Risk-Based Authentication

Write a custom Keycloak Authenticator SPI in Java that scores login risk from impossible travel, new devices and network reputation, then forces step-up MFA or denies access, deployed across the Lab 06 HA cluster.

01Lab Metadata

FieldValue
Lab IDIB-SIA-08
TrackIdentity Bytes, Senior IAM Architect Track
PhasePhase 1, Core Identity Operations
DifficultyAdvanced
Estimated Time5 to 6.5 hours
Core TechnologiesJava 17, Keycloak SPI (Authenticator interface), Maven 3.9, Python 3.11, Flask 3.0, Redis 7.2, Keycloak 24.x (HA cluster), Docker
Builds OnLab 01 (OpenLDAP directory), Lab 02 (Keycloak realm and browser flow), Lab 05 (MFA and WebAuthn, used as the step-up target), Lab 06 (Keycloak High Availability cluster, deployment target for the custom provider)
Feeds IntoLab 09 (JWT/JWS/JWE deep dive), Lab 32 (Threat modelling), Lab 33 (Identity incident response)

02Lab Title and Description

Adaptive and Risk-Based Authentication

Northgate Financial's security operations centre flagged a pattern last quarter: a finance-team credential was used to authenticate successfully from a UK office IP address at 09:14, and again from an IP address geolocated to Lagos, Nigeria at 09:26. Twelve minutes is not enough time to travel that distance. The login succeeded because Keycloak's browser flow, as configured through Lab 05, treats every correct password and every enrolled second factor as equally trustworthy regardless of where or how it was presented. There was no mechanism to say "this specific login looks wrong even though the credentials are correct."

In this lab you close that gap by writing a custom Keycloak Authenticator using the Service Provider Interface (SPI), the extension mechanism Keycloak exposes for adding logic into its authentication flows. Your authenticator calls a purpose-built risk scoring microservice on every login attempt, evaluating impossible travel velocity between the current and previous successful login, whether the combination of IP address and browser fingerprint has been seen for this user before, and whether the source IP falls in a known-bad range. Based on the returned score, the authenticator allows the login through unchanged, forces the step-up one-time passcode flow you built in Lab 05, or denies the attempt outright and raises a Keycloak admin event for the security operations centre to review in Lab 33.

This is deliberately the most involved lab in Phase 1. Writing and packaging a Java SPI, then deploying it consistently across both nodes of the Lab 06 HA cluster without downtime, is representative of the kind of platform engineering a Senior IAM Architect is expected to own, not only configure through a web console.

Estimated completion time: 5 to 6.5 hours, including Java SPI development, deployment across the HA cluster, and verification of all three risk scenarios.

03Prerequisites

Completed Prior Labs

LabWhy it is required
IB-SIA-01, OpenLDAP directorySupplies the user records (asmith, jpatel, lokafor) whose login events this lab scores for risk.
IB-SIA-02, Keycloak realm and browser flowThe custom authenticator you build in this lab is inserted into the existing northgate realm's browser authentication flow, immediately after password validation.
IB-SIA-05, MFA and WebAuthnProvides the OTP and WebAuthn step-up execution that this lab's authenticator conditionally forces for medium-risk logins. Without Lab 05's flow, there is no step-up target to redirect to.
IB-SIA-06, Keycloak High AvailabilityCustom SPI providers must be deployed identically to both ib-keycloak-1 and ib-keycloak-2, and the deployment process must not take the cluster fully offline. This lab is where the operational cost of the HA architecture becomes concrete.

System Requirements

ResourceMinimum
OSUbuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2
RAM10 GB free (the Keycloak HA cluster, Redis and the risk engine together use roughly 3.2 GB)
Disk6 GB free (Maven downloads a local dependency cache on first build)
CPU4 cores recommended; Maven builds are noticeably slower on 2 cores
NetworkOutbound HTTPS access to Maven Central and Docker Hub

Required Tools

ToolExact Version
Docker Engine25.0 or later
Docker Composev2.24 or later
Java Development Kit17.0.10 (Temurin distribution recommended; Keycloak 24.x requires Java 17)
Apache Maven3.9.6
Python3.11.x
curl8.x
jq1.7
Install and verify: Ubuntu/Debian
sudo apt update
sudo apt install -y openjdk-17-jdk maven python3.11 python3.11-venv jq curl

# Verify
java -version        # Expect: openjdk version "17.0.x"
mvn -version          # Expect: Apache Maven 3.9.x, Java version: 17.0.x
python3.11 --version  # Expect: Python 3.11.x
jq --version           # Expect: jq-1.7 or later
Install and verify: macOS
brew install openjdk@17 maven python@3.11 jq
sudo ln -sfn /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk \
  /Library/Java/JavaVirtualMachines/openjdk-17.jdk

java -version
mvn -version
python3.11 --version
jq --version
Install and verify: Windows 11 (WSL2)
# Run inside your WSL2 Ubuntu distribution, not PowerShell
wsl --install -d Ubuntu-22.04

# Then follow the Ubuntu/Debian instructions above inside the WSL2 shell.
SECURITY WARNING This lab modifies the authentication flow of a live Keycloak realm shared with prior labs. Take a configuration export before you begin: docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get realms/northgate > northgate-realm-backup-pre-lab08.json. If a flow misconfiguration locks you out of the admin console, you can restore from this export.

04Real World Problem Statement

Static authentication treats every successful password and OTP as equally trustworthy. It cannot distinguish a genuine login by Amina Smith from her usual office in Manchester from the same credential being replayed from an unfamiliar network on the other side of the world twelve minutes later. Risk-based authentication adds a layer of contextual judgement that static credential checking cannot provide on its own.

Risk

Credential theft and replay are common regardless of how strong the password policy is. A stolen credential used from an unfamiliar location is the single strongest signal available to a login system that something is wrong, yet Northgate's current flow does not evaluate it at all.

Compliance

The FCA's guidance on operational resilience and the PSD2 Regulatory Technical Standards on Strong Customer Authentication both expect firms handling financial data to apply transaction and session risk analysis, not solely static multi-factor checks, where the sensitivity of the access justifies it.

Productivity

Forcing every single login through step-up MFA regardless of context frustrates staff who log in from the same office and device every day. Risk-based authentication allows Northgate to reserve step-up friction for logins that actually warrant it, improving the experience for the overwhelming majority of low-risk logins.

Security Posture

A risk engine that fails closed, denying or challenging when it cannot compute a score, rather than failing open and allowing every login through, materially raises the bar for an attacker holding a valid but stolen credential.

Concrete scenario: Northgate Financial's security operations centre observed the asmith credential authenticate from a Manchester office IP at 09:14 and from a Lagos-geolocated IP at 09:26 on the same day, a physically impossible journey in twelve minutes. In this lab you reproduce that exact scenario against your own lab environment using curl requests that simulate logins from different source IPs, and confirm your risk engine flags the second login and forces step-up authentication before Keycloak issues a token.

05Skills Mapped to Production Solutions

Skill LearnedReal-World Enterprise Application
Writing a custom Keycloak Authenticator using the SPI's org.keycloak.authentication.Authenticator interfaceExtending commercial IAM platforms (Keycloak, ForgeRock, PingFederate) beyond their out-of-the-box authentication policy engine
Designing a rule-based risk scoring model (impossible travel, new device, IP reputation)The foundational logic behind commercial risk engines such as Microsoft Entra ID Protection, Okta ThreatInsight and Transmit Security
Packaging and deploying custom providers consistently across a multi-node Keycloak clusterPlatform engineering discipline required for any production IAM deployment running more than one node
Implementing conditional step-up multi-factor authentication driven by a runtime signal rather than a static policyAdaptive access policy design in enterprise IGA and access management platforms
Designing fail-closed behaviour for a security control dependencyA core resilience principle assessed in IAM architecture reviews and penetration tests: what happens when the risk engine itself is unavailable
Raising structured security events from custom authentication logic into Keycloak's admin event logFeeding identity telemetry into a SIEM for detection engineering, directly relevant to Lab 33's incident response work

06Architecture Overview

BROWSER asmith login attempt ib-lb HAProxy, 8443 ib-keycloak-1 RiskBasedAuthenticator (custom SPI, .jar) ib-keycloak-2 RiskBasedAuthenticator (custom SPI, .jar) RISK ENGINE ib-risk-engine Flask, port 8090 impossible travel, device, IP ib-redis login history, 60 min TTL ib-openldap user directory ib-postgres realm store RISK SCORE OUTCOMES LOW (0-29) Allow, issue token as normal MEDIUM (30-69) Force Lab 05 OTP step-up execution HIGH (70-100) Deny and raise admin event for SOC review 1 score request 2 read/write history 3 score returned 4 attribute lookup Docker network: ib-lab-net

Component Breakdown

ComponentPurposeTechnologyDeploymentPortsKey Configuration
RiskBasedAuthenticatorExecutes inside Keycloak's browser flow immediately after password validation, calling the risk engine and deciding the flow outcomeJava 17, Keycloak SPI (org.keycloak.authentication.Authenticator)Compiled JAR copied to /opt/keycloak/providers on both ib-keycloak-1 and ib-keycloak-2N/A, runs in-processRealm attribute risk-engine-url configured through the Admin Console execution config
Risk engineComputes a 0 to 100 risk score from impossible travel, new device fingerprint and IP reputation signalsPython 3.11, Flask 3.0Docker container ib-risk-engine on ib-lab-net8090Static demo GeoIP mapping and IP reputation list, documented in Section 9
RedisStores each user's last successful login (timestamp, IP, coarse location, device fingerprint) with a 60 minute rolling windowRedis 7.2Docker container ib-redis on ib-lab-net6379 (internal only)No persistence required; login history is intentionally short-lived for this lab
Keycloak HA clusterHosts the modified browser flow and issues tokens once the authenticator's decision is satisfiedKeycloak 24.xExisting ib-keycloak-1/-2 from Lab 06, behind ib-lb8443 (via ib-lb)Browser flow duplicated and extended with the new execution

Data Flow

  1. The browser submits credentials to Keycloak through the load balancer. Whichever node, ib-keycloak-1 or ib-keycloak-2, receives the request runs the same browser flow, since both were built with the identical provider JAR.
    Why: the HA cluster from Lab 06 must behave identically regardless of which node serves a given request; a risk decision that differs by node would be a serious correctness bug.
  2. After password validation succeeds, the RiskBasedAuthenticator calls the risk engine over HTTP, passing the source IP, User-Agent string and username.
    Why: scoring happens after credentials are confirmed correct, not before, so an attacker cannot use response timing differences to enumerate valid usernames.
  3. The risk engine reads the user's last successful login from Redis, computes impossible travel velocity, checks whether the IP and device combination is new, and checks the source IP against a reputation list, then writes the current attempt back to Redis and returns a score.
    Why: keeping login history in fast, short-lived storage rather than the LDAP directory avoids adding write load to the system of record for data that only matters for the next hour.
  4. The authenticator applies the decision: low risk continues the flow unchanged, medium risk redirects into the Lab 05 OTP execution, high risk denies the login and raises a Keycloak admin event.
    Why: a single numeric score is not useful to a security analyst on its own; the admin event carries the contributing factors so Lab 33's incident response work has something concrete to investigate.

Security Considerations

ConcernLab Approach
EncryptionAuthenticator to risk engine traffic runs over plain HTTP on the isolated ib-lab-net Docker network. Section 9 documents the production requirement for mutual TLS on this hop.
Fail-closed behaviourIf the risk engine does not respond within a two second timeout, the authenticator treats this as a high-risk outcome and forces step-up MFA rather than allowing the login through.
AuthenticationThe authenticator calls the risk engine with a shared bearer token, distinct from any user credential, configured as a realm attribute rather than hard-coded in the JAR.
AuditEvery medium and high risk decision raises a custom Keycloak admin event with the contributing risk factors, independent of the risk engine's own application logs.

07Step by Step Implementation

Phase 1: Build the Risk Engine

Step 1.1: Scaffold the risk engine and its login history store

Purpose: create the Flask service that computes risk scores Context: runs independently of Keycloak; the SPI in Phase 2 calls it over HTTP
Project scaffold and dependencies
mkdir -p ~/ib-labs/ib-risk-engine
cd ~/ib-labs/ib-risk-engine
python3.11 -m venv .venv
source .venv/bin/activate

cat > requirements.txt <<'EOF'
flask==3.0.3
redis==5.0.4
gunicorn==22.0.0
EOF

pip install -r requirements.txt
VERIFICATION Run pip list and confirm flask, redis and gunicorn appear with the pinned versions above. If redis fails to install, confirm you activated the virtual environment with source .venv/bin/activate before running pip.

Step 1.2: Implement the demo GeoIP and reputation data

Purpose: provide a small, reproducible location dataset without depending on a licensed GeoIP database Context: imported by risk_logic.py in Step 1.3
geo_data.py, the demo IP to location mapping
# geo_data.py
# Purpose: a small, fully reproducible IP-to-location table for lab use.
# Real production risk engines use a licensed GeoIP database (for example
# MaxMind GeoLite2 or GeoIP2); this lab intentionally avoids that licensing
# and connectivity dependency, and the mapping below covers only the
# addresses this lab's test scripts actually use.

DEMO_IP_LOCATIONS = {
    "51.36.10.20":  {"city": "Manchester, UK",  "lat": 53.4808, "lon": -2.2426},
    "51.36.10.21":  {"city": "Manchester, UK",  "lat": 53.4808, "lon": -2.2426},
    "197.210.55.9": {"city": "Lagos, Nigeria",  "lat": 6.5244,  "lon": 3.3792},
    "203.0.113.44": {"city": "Singapore",       "lat": 1.3521,  "lon": 103.8198},
}

# IP ranges/addresses treated as known-bad for this lab. In production this
# would be sourced from a threat intelligence feed, refreshed regularly.
DEMO_BAD_REPUTATION_IPS = {
    "198.51.100.66",
}


def lookup(ip):
    return DEMO_IP_LOCATIONS.get(ip)


def is_bad_reputation(ip):
    return ip in DEMO_BAD_REPUTATION_IPS
INFO Impossible travel detection depends entirely on knowing the distance between two coordinates. This lab uses the haversine formula, which calculates great-circle distance on a sphere; it is accurate enough for a velocity check even though it is a simplification of the Earth's true shape.

Step 1.3: Implement the risk scoring logic

Purpose: compute a 0 to 100 score from the three signal types Context: reads and writes login history in Redis, imported by app.py in Step 1.4
risk_logic.py, the scoring engine
# risk_logic.py
# Purpose: compute a risk score from impossible travel velocity, new
# device/IP combinations, and IP reputation.

import json
import math
import time
import redis
import geo_data

r = redis.Redis(host="ib-redis", port=6379, decode_responses=True)
HISTORY_TTL_SECONDS = 3600


def _haversine_km(lat1, lon1, lat2, lon2):
    radius_km = 6371.0
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    d_phi = math.radians(lat2 - lat1)
    d_lambda = math.radians(lon2 - lon1)
    a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
    return 2 * radius_km * math.asin(math.sqrt(a))


def _history_key(username):
    return f"login-history:{username}"


def score_login(username, source_ip, user_agent):
    factors = []
    score = 0

    history_raw = r.get(_history_key(username))
    current_location = geo_data.lookup(source_ip)
    now = time.time()

    if history_raw:
        history = json.loads(history_raw)
        previous_location = geo_data.lookup(history["ip"])

        if current_location and previous_location:
            distance_km = _haversine_km(
                previous_location["lat"], previous_location["lon"],
                current_location["lat"], current_location["lon"],
            )
            elapsed_hours = max((now - history["timestamp"]) / 3600.0, 0.01)
            velocity_kmh = distance_km / elapsed_hours

            # Commercial airline cruising speed is roughly 900 km/h; anything
            # meaningfully above that between two logins is not physically
            # plausible travel by the same person.
            if velocity_kmh > 900:
                score += 60
                factors.append(
                    f"impossible_travel: {distance_km:.0f}km in "
                    f"{elapsed_hours*60:.0f} minutes ({velocity_kmh:.0f} km/h)"
                )

        seen_before = (history["ip"] == source_ip and history["user_agent"] == user_agent)
        if not seen_before:
            score += 20
            factors.append("new_device_or_network")
    else:
        # First login ever seen for this user carries a small, non-blocking
        # amount of risk since there is no baseline to compare against.
        score += 10
        factors.append("no_prior_history")

    if geo_data.is_bad_reputation(source_ip):
        score += 40
        factors.append("known_bad_reputation_ip")

    r.setex(
        _history_key(username),
        HISTORY_TTL_SECONDS,
        json.dumps({"ip": source_ip, "user_agent": user_agent, "timestamp": now}),
    )

    return {"score": min(score, 100), "factors": factors}
PRODUCTION CONSIDERATION The 900 km/h velocity threshold and the point values assigned to each factor (60, 20, 40, 10) are illustrative starting points for this lab, not values validated against real fraud data. A production deployment tunes these thresholds against the organisation's actual login patterns and false positive tolerance, typically starting conservative and tightening over time.

Step 1.4: Implement the Flask HTTP layer

Purpose: expose the scoring logic over HTTP for the Java SPI to call Context: calls risk_logic.py from Step 1.3
app.py, the risk engine's HTTP API
# app.py
import os
from flask import Flask, request, jsonify
import risk_logic

app = Flask(__name__)
BEARER_TOKEN = os.environ.get("RISK_ENGINE_BEARER_TOKEN", "change-me-in-production")


@app.before_request
def check_auth():
    if request.path == "/healthz":
        return
    auth = request.headers.get("Authorization", "")
    if auth != f"Bearer {BEARER_TOKEN}":
        return jsonify({"error": "unauthorised"}), 401


@app.get("/healthz")
def healthz():
    return jsonify({"status": "ok"})


@app.post("/score")
def score():
    body = request.get_json(force=True)
    result = risk_logic.score_login(
        username=body["username"],
        source_ip=body["sourceIp"],
        user_agent=body.get("userAgent", "unknown"),
    )
    return jsonify(result)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8090)
Dockerfile and docker-compose.yml addition
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py risk_logic.py geo_data.py .
EXPOSE 8090
CMD ["gunicorn", "--bind", "0.0.0.0:8090", "--workers", "2", "app:app"]
  ib-redis:
    image: redis:7.2-alpine
    container_name: ib-redis
    networks:
      - ib-lab-net

  ib-risk-engine:
    build: ./ib-risk-engine
    container_name: ib-risk-engine
    networks:
      - ib-lab-net
    environment:
      RISK_ENGINE_BEARER_TOKEN: "REPLACE_WITH_STRONG_TOKEN"
    ports:
      - "8090:8090"
    depends_on:
      - ib-redis
Build and start
docker compose up -d --build ib-redis ib-risk-engine
VERIFICATION Run curl -s http://localhost:8090/healthz and confirm {"status": "ok"}. Then run a manual score request: curl -s -X POST http://localhost:8090/score -H "Authorization: Bearer REPLACE_WITH_STRONG_TOKEN" -H "Content-Type: application/json" -d '{"username":"asmith","sourceIp":"51.36.10.20","userAgent":"test"}' and confirm a JSON body with a score field is returned. If the container exits immediately, check docker logs ib-risk-engine for a Redis connection error, which usually means ib-redis has not finished starting yet.

Phase 2: Write the Keycloak Authenticator SPI

Step 2.1: Scaffold the Maven project

Purpose: set up the Java project structure targeting the Keycloak SPI Context: compiled output is a JAR deployed to both Keycloak nodes in Phase 3
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>uk.co.identitybytes</groupId>
  <artifactId>risk-based-authenticator</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <keycloak.version>24.0.5</keycloak.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.keycloak</groupId>
      <artifactId>keycloak-server-spi</artifactId>
      <version>${keycloak.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.keycloak</groupId>
      <artifactId>keycloak-server-spi-private</artifactId>
      <version>${keycloak.version}</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.keycloak</groupId>
      <artifactId>keycloak-services</artifactId>
      <version>${keycloak.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>
</project>
INFO The provided scope tells Maven that Keycloak supplies these classes at runtime from its own module path; they must not be bundled into your JAR, since a duplicate copy of Keycloak's own classes on the providers classpath causes class loading conflicts.

Step 2.2: Implement the Authenticator

Purpose: call the risk engine and control the flow outcome Context: registered into the browser flow in Phase 3
RiskBasedAuthenticator.java
package uk.co.identitybytes.risk;

import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.events.EventType;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.HashMap;
import javax.json.Json;
import javax.json.JsonObject;

// Calls the ib-risk-engine service after password validation and decides
// whether to continue the flow, force step-up MFA, or deny the login.
public class RiskBasedAuthenticator implements Authenticator {

    private static final Duration TIMEOUT = Duration.ofSeconds(2);

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        RealmModel realm = context.getRealm();
        UserModel user = context.getUser();
        String riskEngineUrl = realm.getAttribute("risk-engine-url");
        String bearerToken = realm.getAttribute("risk-engine-bearer-token");

        String sourceIp = context.getConnection().getRemoteAddr();
        String userAgent = context.getHttpRequest().getHttpHeaders()
                .getRequestHeaders().getFirst("User-Agent");

        try {
            JsonObject requestBody = Json.createObjectBuilder()
                    .add("username", user.getUsername())
                    .add("sourceIp", sourceIp)
                    .add("userAgent", userAgent != null ? userAgent : "unknown")
                    .build();

            HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(riskEngineUrl + "/score"))
                    .timeout(TIMEOUT)
                    .header("Authorization", "Bearer " + bearerToken)
                    .header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString()))
                    .build();

            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
            JsonObject result = Json.createReader(new java.io.StringReader(response.body())).readObject();
            int score = result.getInt("score");

            applyDecision(context, score, result.getJsonArray("factors").toString());

        } catch (Exception e) {
            // Fail closed: if the risk engine is unreachable or returns an
            // error, treat the login as high risk rather than allowing it
            // through unchecked.
            raiseAdminEvent(context, 100, "risk_engine_unreachable: " + e.getMessage());
            context.getEvent().error("risk_engine_unreachable");
            context.challenge(context.form()
                    .setError("Unable to verify login risk. Please contact IT support.")
                    .createErrorPage(javax.ws.rs.core.Response.Status.SERVICE_UNAVAILABLE));
            context.failure(AuthenticationFlowError.INTERNAL_ERROR);
        }
    }

    private void applyDecision(AuthenticationFlowContext context, int score, String factors) {
        if (score < 30) {
            context.success();
        } else if (score < 70) {
            raiseAdminEvent(context, score, factors);
            // Marks the session so the subsequent OTP execution in the flow
            // (built in Lab 05) is required rather than skipped, even for
            // a user who might otherwise be within their OTP "remember me"
            // window.
            context.getAuthenticationSession().setAuthNote("force_otp_step_up", "true");
            context.attempted();
        } else {
            raiseAdminEvent(context, score, factors);
            context.getEvent().error("risk_denied_high_score");
            context.challenge(context.form()
                    .setError("This login was blocked for security review. Contact IT support.")
                    .createErrorPage(javax.ws.rs.core.Response.Status.FORBIDDEN));
            context.failure(AuthenticationFlowError.ACCESS_DENIED);
        }
    }

    private void raiseAdminEvent(AuthenticationFlowContext context, int score, String factors) {
        context.getEvent()
                .event(EventType.CUSTOM_REQUIRED_ACTION)
                .detail("risk_score", String.valueOf(score))
                .detail("risk_factors", factors)
                .success();
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // No user-facing form for this authenticator; it acts purely on
        // the previous step's credential and the risk engine's response.
    }

    @Override
    public boolean requiresUser() {
        return true;
    }

    @Override
    public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
        return true;
    }

    @Override
    public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
    }

    @Override
    public void close() {
    }
}
SECURITY WARNING The fail-closed catch block is the single most important part of this class. An authenticator that fails open, silently calling context.success() when the risk engine cannot be reached, converts a security control into a control that only works when nothing has gone wrong, which is precisely when it is least useful.

Step 2.3: Implement the AuthenticatorFactory

Purpose: register the authenticator with Keycloak's SPI discovery mechanism and expose its configuration fields Context: makes RiskBasedAuthenticator selectable in the Admin Console flow editor
RiskBasedAuthenticatorFactory.java
package uk.co.identitybytes.risk;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.List;

public class RiskBasedAuthenticatorFactory implements AuthenticatorFactory {

    public static final String PROVIDER_ID = "risk-based-authenticator";
    private static final RiskBasedAuthenticator SINGLETON = new RiskBasedAuthenticator();

    @Override
    public String getId() {
        return PROVIDER_ID;
    }

    @Override
    public String getDisplayType() {
        return "Risk-Based Step-Up (Identity Bytes)";
    }

    @Override
    public String getReferenceCategory() {
        return "risk";
    }

    @Override
    public boolean isConfigurable() {
        return true;
    }

    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return new AuthenticationExecutionModel.Requirement[]{
                AuthenticationExecutionModel.Requirement.REQUIRED,
                AuthenticationExecutionModel.Requirement.DISABLED,
        };
    }

    @Override
    public boolean isUserSetupAllowed() {
        return false;
    }

    @Override
    public String getHelpText() {
        return "Scores login risk via an external engine and forces step-up MFA or denies access based on the result.";
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return List.of();
    }

    @Override
    public Authenticator create(KeycloakSession session) {
        return SINGLETON;
    }

    @Override
    public void init(Config.Scope config) {
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
    }

    @Override
    public void close() {
    }
}
SPI registration file
# src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
uk.co.identitybytes.risk.RiskBasedAuthenticatorFactory
Build the JAR
cd ~/ib-labs/risk-based-authenticator
mvn clean package
VERIFICATION Confirm target/risk-based-authenticator-1.0.0.jar exists after the build. Run jar tf target/risk-based-authenticator-1.0.0.jar | grep META-INF/services and confirm the SPI registration file is present in the JAR; without it, Keycloak's provider discovery will not find your class no matter where the JAR is placed. If the Maven build fails with a missing dependency, confirm your keycloak.version in pom.xml matches the version running in your ib-keycloak-1 container, checked with docker exec -it ib-keycloak-1 /opt/keycloak/bin/kc.sh --version.

Phase 3: Deploy Across the HA Cluster and Wire the Flow

Step 3.1: Deploy the provider JAR to both Keycloak nodes without full downtime

Purpose: make the new authenticator available on every node identically Context: exercises the HA architecture built in Lab 06, which routes around a single node while it restarts
Rolling provider deployment
# Copy the JAR to both nodes' providers directory
docker cp target/risk-based-authenticator-1.0.0.jar ib-keycloak-1:/opt/keycloak/providers/
docker cp target/risk-based-authenticator-1.0.0.jar ib-keycloak-2:/opt/keycloak/providers/

# Rebuild the Keycloak distribution inside each container so the new
# provider is registered, one node at a time so ib-lb keeps routing
# traffic to the other node throughout.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kc.sh build
docker restart ib-keycloak-1

# Wait for ib-keycloak-1 to report healthy before touching the second node
until curl -sk https://localhost:8443/health/ready | grep -q UP; do sleep 3; done

docker exec -it ib-keycloak-2 /opt/keycloak/bin/kc.sh build
docker restart ib-keycloak-2
VERIFICATION Run docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get authentication/providers -r northgate | jq '.[] | select(.id=="risk-based-authenticator")'. Expect a single JSON object confirming the provider is registered. Repeat against ib-keycloak-2. If the provider is missing on one node only, the kc.sh build step likely did not complete before the restart; re-run it and confirm it exits with status 0 before restarting.
PRODUCTION CONSIDERATION This lab's rolling restart is a manual approximation of what a Kubernetes rolling deployment or a blue-green Keycloak deployment does automatically. In production, provider JARs are typically baked into a custom Keycloak container image built through a CI pipeline, rather than copied into a running container, so that every replica started by an orchestrator already has the correct providers from the moment it starts.

Step 3.2: Configure the realm attributes the authenticator reads

Purpose: supply the risk engine URL and bearer token without hard-coding them in the JAR Context: read by RiskBasedAuthenticator.authenticate() in Step 2.2
Set realm attributes via kcadm.sh
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update realms/northgate \
  -s 'attributes."risk-engine-url"=http://ib-risk-engine:8090' \
  -s 'attributes."risk-engine-bearer-token"=REPLACE_WITH_STRONG_TOKEN'
VERIFICATION Run docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get realms/northgate --fields attributes | jq and confirm both attributes appear with the expected values. Because the realm lives in the shared PostgreSQL instance from Lab 06, this update is immediately visible from ib-keycloak-2 as well; no separate configuration step is needed on the second node.

Step 3.3: Duplicate the browser flow and insert the new execution

Purpose: place the risk check immediately after password validation, before the OTP execution from Lab 05 Context: modifies the flow used for every login to the northgate realm
Duplicate and edit the flow (Admin Console steps, scripted equivalent below)
# Duplicate the existing browser flow so the original remains available
# as a rollback path
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create \
  authentication/flows/browser/copy -r northgate \
  -s newName="browser-risk-based"

# Add the new execution to the copied flow
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create \
  authentication/flows/browser-risk-based/executions/execution -r northgate \
  -s provider=risk-based-authenticator

# Set it to REQUIRED and position it after password validation, before OTP.
# In the Admin Console: Authentication, browser-risk-based, drag
# "Risk-Based Step-Up (Identity Bytes)" to sit directly beneath
# "Username Password Form" and above the Lab 05 "OTP Form" execution,
# then set its requirement to Required.

# Bind the new flow as the realm's active browser flow
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
  realms/northgate -s browserFlow=browser-risk-based
VERIFICATION Log in to the Keycloak Admin Console, navigate to Authentication, and confirm browser-risk-based is marked as the realm's bound browser flow, with "Risk-Based Step-Up (Identity Bytes)" listed as Required between the password form and the OTP form. If the execution does not appear in the dropdown when adding it, the provider was not correctly registered in Step 3.1; re-verify with the kcadm.sh get authentication/providers command from that step.
SECURITY WARNING Do not delete the original browser flow. Keeping it available and unbound gives you an immediate rollback path (kcadm.sh update realms/northgate -s browserFlow=browser) if the new flow misbehaves during testing.
What just happened? You extended Keycloak beyond its built-in authentication policy with a custom Java Authenticator that consults an external risk engine on every login. The engine reasons about impossible travel, unfamiliar devices and network reputation, three signals a static password and OTP check can never see, and the authenticator turns that score into a concrete flow decision: allow, step up, or deny. Deploying the JAR across both nodes of the Lab 06 HA cluster without a full outage is exactly the kind of operational discipline that separates a demo from a production-grade identity platform.

08Testing and Validation

End-to-End Test Scenarios

ScenarioStepsExpected Result
Low risk, familiar loginAuthenticate as asmith from 51.36.10.20, the demo Manchester IP, using a device already seen onceLogin proceeds directly to token issuance, no OTP prompt shown
Medium risk, new deviceAuthenticate as asmith again immediately, changing only the User-Agent header to an unseen valueScore lands in the 30 to 69 range; OTP step-up is forced even if asmith's device was previously remembered
High risk, impossible travelAuthenticate as asmith from 51.36.10.20, then within two minutes authenticate again from 197.210.55.9, the demo Lagos IPScore reaches 70 or above; login is denied with a 403 and an admin event is raised
Impossible travel test script
#!/usr/bin/env bash
set -euo pipefail
TOKEN="REPLACE_WITH_STRONG_TOKEN"

echo "First login, Manchester:"
curl -s -X POST http://localhost:8090/score \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"username":"asmith","sourceIp":"51.36.10.20","userAgent":"lab-test"}' | jq .

sleep 2

echo "Second login, Lagos, 2 seconds later:"
curl -s -X POST http://localhost:8090/score \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"username":"asmith","sourceIp":"197.210.55.9","userAgent":"lab-test"}' | jq .

# Expect the second call's score to include "impossible_travel" in its
# factors array and a total score of 70 or above.

Negative Tests

TestExpected Result
Stop ib-risk-engine with docker stop ib-risk-engine, then attempt a loginAuthenticator's HTTP call times out after two seconds; login fails closed with a service unavailable error, not a silent allow
Call /score with no Authorization headerHTTP 401 returned by the risk engine
Call /score with a username never seen beforeScore includes no_prior_history as a low-weight factor, not a crash

Common Failure Modes

SymptomLikely CauseResolution
Authenticator does not appear in the flow execution dropdownSPI registration file missing from the JAR, or kc.sh build not run after copying the JARRe-check Step 2.3's verification and Step 3.1's build step
Login always succeeds regardless of the scenario testedThe realm's active browser flow is still the original browser flow, not browser-risk-basedConfirm with kcadm.sh get realms/northgate --fields browserFlow
Score is always identical across two different source IPsThe demo GeoIP table in geo_data.py does not contain the IP you tested withAdd the IP to DEMO_IP_LOCATIONS, or use one of the IPs already listed there
One node applies the flow correctly and the other does notThe JAR or the kc.sh build step was only performed on one nodeRepeat Step 3.1 against the node that is missing the provider

09Security Analysis

What Makes This Implementation Secure

What Is Intentionally Simplified for the Lab

Production Hardening Recommendations

AreaRecommendation
Threat intelligenceReplace the static demo reputation list with a subscribed threat intelligence feed, refreshed on a defined schedule
GeoIP accuracyReplace the demo location table with a licensed GeoIP database such as MaxMind GeoIP2, refreshed regularly per the vendor's update cadence
Transport securityEnforce mutual TLS between the authenticator and the risk engine, with certificates issued by the internal CA built in the Phase 4 PKI labs
Threshold tuningRun the risk engine in shadow mode (logging scores without enforcing them) against real login traffic before switching enforcement on, to calibrate thresholds against actual false positive rates
Provider deploymentBake the provider JAR into a custom Keycloak container image built by CI, rather than copying it into a running container, so every replica in an orchestrated environment starts consistently configured

10Cleanup

Revert to the original browser flow while keeping the risk engine available
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update \
  realms/northgate -s browserFlow=browser

docker compose stop ib-risk-engine ib-redis
docker compose rm -f ib-risk-engine ib-redis
Remove the custom provider entirely (optional, full teardown only)
docker exec -it ib-keycloak-1 rm /opt/keycloak/providers/risk-based-authenticator-1.0.0.jar
docker exec -it ib-keycloak-2 rm /opt/keycloak/providers/risk-based-authenticator-1.0.0.jar

docker exec -it ib-keycloak-1 /opt/keycloak/bin/kc.sh build
docker restart ib-keycloak-1

until curl -sk https://localhost:8443/health/ready | grep -q UP; do sleep 3; done

docker exec -it ib-keycloak-2 /opt/keycloak/bin/kc.sh build
docker restart ib-keycloak-2
VERIFICATION After cleanup, confirm docker ps no longer lists ib-risk-engine or ib-redis, while ib-openldap, ib-keycloak-1, ib-keycloak-2, ib-lb and ib-postgres remain healthy and ready for Lab 09.

11Recommended Learning Links

12Portfolio Publishing Guide

Sanitise Before Publishing

Before pushing this lab's code to a public repository, remove every placeholder secret and confirm the Maven build artefacts are excluded.

Sanitisation checklist commands
grep -R "REPLACE_WITH" . --include="*.java" --include="*.py" --include="*.yml" || echo "Clean"

cat >> .gitignore <<'EOF'
target/
.venv/
*.jar
.env
EOF

git status

README for the Repository

README.md skeleton
# IB-SIA-08: Adaptive and Risk-Based Authentication

A custom Keycloak Authenticator SPI (Java 17) that scores login risk via an
external Flask engine (impossible travel, new device, IP reputation) and
forces step-up MFA or denies access, deployed across a Keycloak HA cluster.

## Stack
Java 17, Keycloak SPI, Maven 3.9, Python 3.11, Flask 3.0, Redis 7.2

## Scenarios demonstrated
- Low risk: familiar device and location, no step-up required
- Medium risk: new device, forces OTP step-up
- High risk: impossible travel, login denied and admin event raised

## Part of the Identity Bytes Senior IAM Architect Track
Lab 8 of 36. See identity-bytes.com for the full curriculum.

Git Commands

Commit and push
git add pom.xml src/ app.py risk_logic.py geo_data.py Dockerfile README.md
git commit -m "IB-SIA-08: Adaptive risk-based authentication via custom Keycloak SPI"
git push origin main

Track Index Line

Add the following line to your master portfolio index:

IB-SIA-08 | Adaptive and Risk-Based Authentication | Advanced | Keycloak SPI, Java, risk scoring, HA deployment

LinkedIn Draft

A correct password and a valid one-time code are not the same thing as a legitimate login.

This week I wrote a custom Keycloak Authenticator in Java that scores every login for risk before a token is ever issued. Twelve minutes between a login in Manchester and a login in Lagos is not a slow VPN, it is not physically possible travel, and a static authentication flow has no way to know that.

The engineering challenge was not the risk logic itself, that part is a straightforward set of rules. It was deploying a custom SPI provider consistently across a two-node Keycloak cluster without taking the login flow down, and deciding what should happen when the risk engine itself does not respond. A security control that quietly lets everyone through the moment it breaks is not a security control, so the authenticator fails closed and forces step-up MFA whenever it cannot get a clear answer.

Static multi-factor authentication asks "did you prove who you are." Risk-based authentication also asks "does this specific login make sense."

Where in your identity stack are you still treating every login as equally trustworthy regardless of context?

Next: IB-SIA-09, JWT/JWS/JWE Deep Dive
Phase 2, Token Engineering, begins by pulling apart the tokens Keycloak has been issuing throughout Phase 1: their structure, their signing, and where encryption fits in.