All articles Integration

Keycloak Registration Flows: Adding a Device-Intelligence Check

Keycloak’s self-registration is a single toggle in the realm settings, and the moment you flip it, anyone can create as many accounts as they have email addresses. Email verification slows a human down a little. It does nothing against someone recycling one laptop through a stack of inboxes.

Keycloak already has the right extension point for a device check: the registration flow is a chain of FormActions, each of which can validate the submitted form and fail it with a message. This guide adds one that verifies a Prynt identification server side with the Java SDK and refuses registrations from devices that are blocked or already hold too many accounts.

How the pieces fit

  • Theme (register.ftl): loads the Prynt agent, runs identify() when the form is submitted, and writes the requestId into a hidden field.
  • PryntFormAction.validate(): reads the field, calls GET /v1/events/{requestId} with your secret key, and fails the form if the device is over the limit.
  • PryntFormAction.success(): runs after Keycloak creates the user, and attaches the user id to the event with PUT /v1/events/{requestId}.

That last step matters as much as the check. The accountsOnDevice list in each event only contains accounts you have linked, so without it every device looks new. See accountsOnDevice explained for how the count is built.

Step 1: the theme

Extend your login theme and override register.ftl. Add a hidden input inside the existing kc-register-form and a small script:

<input type="hidden" id="prynt_request_id" name="prynt_request_id" />

<script src="https://api.pryntid.com/cdn/prynt.umd.js"></script>
<script>
  (function () {
    var form = document.getElementById('kc-register-form');
    var agent = Prynt.load({ apiKey: 'pk_live_…' });
    var ready = false;
    form.addEventListener('submit', function (e) {
      if (ready) return;
      e.preventDefault();
      agent
        .then(function (p) { return p.identify({ tag: { action: 'signup' } }); })
        .then(function (r) { document.getElementById('prynt_request_id').value = r.requestId; })
        .catch(function () { /* leave empty: the server treats it as missing */ })
        .finally(function () { ready = true; form.submit(); });
    });
  })();
</script>

The public pk_live_… key is safe here. It can identify but cannot read events. If your realm sends a strict Content-Security-Policy, add api.pryntid.com to script-src and connect-src, or serve the agent from your own domain through a first-party proxy.

Step 2: the FormAction

Add the Prynt Java SDK to your provider JAR. It has no dependencies and returns raw JSON strings, and Keycloak already ships Jackson for parsing them.

package com.example.prynt;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.prynt.PryntServer;
import jakarta.ws.rs.core.MultivaluedMap;
import java.time.Duration;
import java.util.List;
import org.keycloak.authentication.*;
import org.keycloak.events.Errors;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.*;
import org.keycloak.models.utils.FormMessage;

public class PryntFormAction implements FormAction {
  static final String FIELD = "prynt_request_id";
  static final int MAX_OTHER_ACCOUNTS = 1;
  private static final ObjectMapper JSON = new ObjectMapper();
  private final PryntServer prynt = new PryntServer(
      System.getenv("PRYNT_SECRET_KEY"), PryntServer.DEFAULT_ENDPOINT, Duration.ofSeconds(3));

  @Override
  public void validate(ValidationContext ctx) {
    MultivaluedMap<String, String> form = ctx.getHttpRequest().getDecodedFormParameters();
    String requestId = form.getFirst(FIELD);
    if (requestId == null || !requestId.matches("[A-Za-z0-9_-]{4,100}")) {
      ctx.success();                 // missing: allow, but nothing to link
      return;
    }
    try {
      JsonNode ev = JSON.readTree(prynt.getEvent(requestId));
      JsonNode aod = ev.path("accountsOnDevice");
      boolean replay = ev.hasNonNull("linkedId");
      boolean overLimit = aod.path("truncated").asBoolean(false)
          || aod.path("count").asInt(0) >= MAX_OTHER_ACCOUNTS;
      if (replay || overLimit || "block".equals(ev.path("decision").asText())) {
        ctx.error(Errors.INVALID_REGISTRATION);
        ctx.validationError(form, List.of(new FormMessage(null, "pryntDeviceLimit")));
        return;
      }
      ctx.getAuthenticationSession().setAuthNote(FIELD, requestId);
      ctx.success();
    } catch (PryntServer.PryntException e) {
      ctx.success();                 // 404 or API error: fail open
    } catch (Exception e) {
      ctx.success();
    }
  }

  @Override
  public void success(FormContext ctx) {
    String requestId = ctx.getAuthenticationSession().getAuthNote(FIELD);
    if (requestId == null) return;
    UserModel user = ctx.getUser();
    user.setSingleAttribute(FIELD, requestId);   // audit trail + event-listener option
    try {
      prynt.updateEvent(requestId, user.getId());
    } catch (Exception e) {
      // leave the attribute; a listener or backfill job can retry
    }
  }

  @Override public void buildPage(FormContext ctx, LoginFormsProvider form) {}
  @Override public boolean requiresUser() { return false; }
  @Override public boolean configuredFor(KeycloakSession s, RealmModel r, UserModel u) { return true; }
  @Override public void setRequiredActions(KeycloakSession s, RealmModel r, UserModel u) {}
  @Override public void close() {}
}

pryntDeviceLimit is a message key. Add it to your theme’s messages_en.properties with copy that does not accuse anyone, for example “We couldn’t create an account from this device. If you think this is a mistake, contact support.” Handling appeals covers what happens after that message.

Two details in validate() are deliberate.

Replay check. A requestId that already carries a linkedId belongs to an earlier registration. Refusing it stops someone from capturing one clean identification and reusing it for every signup.

Fail open. Network errors and unknown ids let the registration through. A Prynt outage should never become a Keycloak outage, and visitors with Global Privacy Control enabled get no stored event by default. If you prefer, store a “missing” attribute and review those users later instead of letting them through silently.

Step 3: the factory and registration

Keycloak discovers providers through a factory listed in META-INF/services/org.keycloak.authentication.FormActionFactory:

public class PryntFormActionFactory implements FormActionFactory {
  private static final PryntFormAction INSTANCE = new PryntFormAction();
  public String getId() { return "prynt-device-check"; }
  public String getDisplayType() { return "Prynt device check"; }
  public FormAction create(KeycloakSession session) { return INSTANCE; }
  public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
    return new AuthenticationExecutionModel.Requirement[] {
      AuthenticationExecutionModel.Requirement.REQUIRED,
      AuthenticationExecutionModel.Requirement.DISABLED };
  }
  // isConfigurable, isUserSetupAllowed, getHelpText, getConfigProperties,
  // getReferenceCategory, init, postInit, close: standard boilerplate
}

Build the JAR, drop it into providers/, run kc.sh build, and restart. Then, in the admin console:

  1. Authentication → Flows, duplicate the built-in registration flow.
  2. Inside the registration form sub-flow, add the “Prynt device check” step and set it to Required.
  3. Bind the copy as the realm’s registration flow.

Keep the secret key in the environment or a vault, never in the realm configuration where admins with view rights could read it.

Linking with an event listener instead

Calling the API inside success() is simple, but it keeps an HTTP call inside the registration transaction. If you would rather decouple, drop the updateEvent call and let an EventListenerProvider handle it:

@Override
public void onEvent(Event event) {
  if (event.getType() != EventType.REGISTER) return;
  RealmModel realm = session.realms().getRealm(event.getRealmId());
  UserModel user = session.users().getUserById(realm, event.getUserId());
  String requestId = user == null ? null : user.getFirstAttribute("prynt_request_id");
  if (requestId != null) queue.submit(() -> prynt.updateEvent(requestId, user.getId()));
}

Enable the listener under Realm settings → Events. The same listener can watch LOGIN events, and if your client apps pass linkedId to identify() after sign-in, every device a user logs in from gets attached too. That history is what powers SaaS account security checks later.

Paths that skip the registration form

A FormAction only guards the registration form. Keycloak has other ways to create users, and each one needs its own decision.

Identity brokering. A user who signs in with Google, GitHub or a SAML provider for the first time goes through the first broker login flow, not the registration flow, so validate() never runs. If social login is where your free accounts come from, the cleanest option is to make the identification part of the login page instead: put the same hidden field and script on login.ftl, store the requestId in an auth note with a small authenticator early in the first broker login flow, and run the same check there before the account is created.

Admin-created and API-created users. Users created through the Admin REST API or the admin console bypass every form flow. That is usually fine, since those are invited or provisioned accounts, but make sure your free-tier logic does not treat them as self-serve signups.

Re-registration after deletion. If you delete abusive accounts, the device history in Prynt still lists their old linkedIds, so the next registration from that device is still counted against the limit. That is what you want for ban evasion, but remember it when a legitimate user asks to start over: the support path should be able to approve them rather than relying on the count resetting.

Whatever you decide for each path, write it down next to the flow configuration. The most common gap in a Keycloak rollout is a second way in that nobody remembered existed.

Rolling it out

Start with the action set to Required but validate() always calling ctx.success() and logging what it would have done. A week of those logs tells you whether one account per device fits your users, or whether shared machines in offices and classrooms need a limit of 2 or 3. The device-based signup limits guide has the reasoning, and protecting signup from fraud covers the checks you’ll want next to it, such as disposable email and bot signals.

Once the logs look sane, switch on the validationError branch and watch your support inbox for the first week.

Try it free

Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.

Keep reading