Spring Boot Fraud Detection Without a Java SDK

A RestClient bean, an interceptor, and the three Spring details that decide whether this survives production: pooling, timeouts, and failing open.

A worker in a hard hat crouches among giant steel coil springs
Photo: NORADSprings1 by Bob Simons, U.S. Air Force, Public domain, cropped. Via Wikimedia Commons.
On this page
  1. Step 1: Add the script to the page with your form
  2. Step 2: Send the check from your server
  3. Wire it once, not per controller
  4. The review band is not a block
  5. Step 3: Submit your form once
  6. Virtual threads, and why the timeout still matters
  7. The short version

There is no Java SDK. For an API with one endpoint that matters, the wrapper is a RestClient bean and a record, well under a hundred lines including the failure handling. Writing it yourself means the timeout, the pool size and the fail-open policy are decisions you made rather than defaults you inherited, and in Spring the defaults are exactly where this goes wrong.

Three of those defaults will hurt you in production, and none of them has anything to do with fraud: RestClient ships with no read timeout, the stock HTTP client opens a fresh connection per call, and a @ControllerAdvice that has never seen a vendor outage will turn a detection blip into a 500 on your signup form.

Written for Spring Boot 3.2 or later on Java 21, in the three steps the Maskbreak console lists: add the script to the page with your form, send the check from your server, and submit your form once. It starts in watch mode, where every check is logged and nothing is refused.

Step 1: Add the script to the page with your form

One script tag in your layout, and class="monocle-enriched" on the <form> element itself (never on an input):

html
<!-- src/main/resources/templates/layout.html -->
<script async src="https://maskbreak.com/assets/sentinel.js"></script>

<form method="post" action="/register" class="monocle-enriched">
  <input type="email" name="email" required>
  <input type="password" name="password" required>
  <button type="submit">Create account</button>
</form>

The script adds three hidden fields to that form: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone). They arrive as ordinary request parameters. For a JavaScript frontend, call await window.Sentinel.collect() in the browser (check that window.Sentinel exists, and submit anyway when it does not) and post its token, fingerprintEventId and tz as form fields (URLSearchParams or FormData) alongside your own, so they still arrive as request parameters. No API key goes anywhere near the browser.

If Spring Security writes a Content-Security-Policy, add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and 'wasm-unsafe-eval' to script-src, the mcl hosts (https and wss) and mesu to connect-src, and https://mesu.maskbreak.com blob: to worker-src.

Step 2: Send the check from your server

Spring's RestClient.builder() gives you no read timeout at all unless you set one. A vendor whose TCP connect succeeds but whose response never arrives would hold your request thread until something else gives up, and on a signup endpoint "something else" is usually the user.

java
// src/main/java/com/example/fraud/FraudConfig.java
@Configuration
public class FraudConfig {

  @Bean
  // Empty when unset: the app still starts, and every check logs a 401 at error.
  RestClient maskbreakClient(@Value("${MASKBREAK_API_KEY:}") String key) {
    var factory = new JdkClientHttpRequestFactory();
    // Short on purpose: this sits in front of a user-facing action.
    factory.setReadTimeout(Duration.ofSeconds(3));

    return RestClient.builder()
        .baseUrl("https://maskbreak.com")
        .defaultHeader("Authorization", "Bearer " + key)
        .defaultHeader("Content-Type", "application/json")
        .requestFactory(factory)
        .build();
  }
}

JdkClientHttpRequestFactory wraps the JDK's own HttpClient, which keeps connections alive between calls; the older SimpleClientHttpRequestFactory does not pool at all and hands you a fresh TLS handshake on every signup. If Apache HttpClient 5 is on your classpath, size its pool explicitly. The key comes from the MASKBREAK_API_KEY environment variable (or your secrets manager), never application.properties in the repository.

The response field is risk_score and your record component is riskScore. Jackson will not bridge that gap on its own, and the failure is silent: a valid object with a zero in it. Annotate the fields you read:

java
// src/main/java/com/example/fraud/Verdict.java
@JsonIgnoreProperties(ignoreUnknown = true)
public record Verdict(
    String decision,                                  // allow | review | block
    @JsonProperty("risk_score") int riskScore,
    List<String> reasons,
    @JsonProperty("device_evidence") String deviceEvidence,   // ok | not_sent | unavailable
    boolean degraded,
    boolean test) {

  public boolean blocked() { return "block".equals(decision); }

  /** A complete, production allow: what a strict action needs in enforce mode. */
  public boolean cleanAllow() {
    return "allow".equals(decision) && "ok".equals(deviceEvidence) && !degraded && !test;
  }
}

The service sends whatever evidence arrived, including none: in watch mode a check without a token or a browser check id still has to reach Maskbreak, because that is how the console tells you which half is missing. It never throws.

java
// src/main/java/com/example/fraud/FraudService.java
@Service
public class FraudService {

  private static final Logger log = LoggerFactory.getLogger("maskbreak");
  private final RestClient client;
  final String mode = Objects.requireNonNullElse(System.getenv("MASKBREAK_MODE"), "watch");

  FraudService(RestClient maskbreakClient) { this.client = maskbreakClient; }

  public boolean isEnforcing() { return "enforce".equals(mode); }

  /** Returns null when the verdict is unavailable. Null means "no opinion",
   *  never "safe". */
  public Verdict evaluate(HttpServletRequest req, String accountId) {
    var body = new HashMap<String, Object>();
    body.put("token", Objects.requireNonNullElse(first(req, "monocle", "token"), ""));
    putIfPresent(body, "fingerprintEventId", first(req, "sentinel_fp", "fingerprintEventId"));
    putIfPresent(body, "tz", first(req, "sentinel_tz", "tz"));
    putIfPresent(body, "email", req.getParameter("email"));
    putIfPresent(body, "accountId", accountId);

    Verdict v = null;
    try {
      v = client.post().uri("/v1/evaluate").body(body).retrieve().body(Verdict.class);
    } catch (HttpClientErrorException e) {
      // 4xx is our bug: bad key, malformed body. Loud.
      log.error("maskbreak rejected the request: {}", e.getStatusCode());
    } catch (RestClientException e) {
      // Timeout, DNS, connection reset, their 5xx. Countable, not fatal.
      log.warn("maskbreak unavailable: {}", e.getClass().getSimpleName());
    }
    // One line per check. Never log the key, the token or form data.
    log.info("maskbreak mode={} decision={} reasons={} device_evidence={} degraded={}",
        mode, v == null ? null : v.decision(), v == null ? null : v.reasons(),
        v == null ? null : v.deviceEvidence(), v == null ? null : v.degraded());
    return v;
  }

  private static String first(HttpServletRequest req, String a, String b) {
    var x = req.getParameter(a);
    return (x != null && !x.isBlank()) ? x : req.getParameter(b);
  }

  private static void putIfPresent(Map<String, Object> m, String k, String v) {
    if (v != null && !v.isBlank()) m.put(k, v);
  }
}

Those two catch clauses are the whole failure design. A 4xx is a mistake you made and it will not fix itself, so it belongs at error where someone will see it. A timeout is weather. Returning null for both is deliberate: the calling code decides what an absent verdict means, and that differs between a newsletter form and a withdrawal. Do not let this method throw; if a detection outage propagates out of here, your @ControllerAdvice turns it into a 500.

Try it

Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.

Get an API key

Wire it once, not per controller

A HandlerInterceptor runs after Spring has worked out which handler serves the request, which means you can key the policy off an annotation instead of a URL list that drifts. In watch mode it records the verdict and always continues.

java
// Screen.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Screen {
  /** Enforce mode: true continues only on a clean, complete allow. */
  boolean strict() default false;
}

// FraudInterceptor.java
@Component
public class FraudInterceptor implements HandlerInterceptor {

  private final FraudService fraud;

  FraudInterceptor(FraudService fraud) { this.fraud = fraud; }

  @Override
  public boolean preHandle(HttpServletRequest req, HttpServletResponse res,
                           Object handler) throws IOException {
    if (!(handler instanceof HandlerMethod hm)) return true;
    var screen = hm.getMethodAnnotation(Screen.class);
    if (screen == null) return true;

    var verdict = fraud.evaluate(req, currentAccountId(req));
    req.setAttribute("verdict", verdict);

    if (!fraud.isEnforcing()) return true;   // watch: log only

    boolean refuse = (verdict != null && verdict.blocked())
        || (screen.strict() && (verdict == null || !verdict.cleanAllow()));
    if (refuse) {
      res.setStatus(verdict != null && verdict.blocked() ? 403 : 409);
      res.setContentType("application/json");
      res.getWriter().write("{\"error\":\"refused\"}");
      return false;
    }
    return true;
  }
}

Register it against the paths that need it, not against everything; screening a health check is a per-request cost with no upside:

java
@Configuration
class WebConfig implements WebMvcConfigurer {
  private final FraudInterceptor interceptor;
  WebConfig(FraudInterceptor i) { this.interceptor = i; }

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(interceptor)
            .addPathPatterns("/register", "/login", "/checkout", "/withdraw");
  }
}

Then the policy lives on the handler, where the person changing it can see what it protects:

java
// Signup: in enforce mode, refuse only a block. VPN users are customers.
@Screen
@PostMapping("/register")
public String register(@Valid SignupForm form) { ... }

// Money leaving: in enforce mode, only a clean, complete allow continues.
@Screen(strict = true)
@PostMapping("/withdraw")
public String withdraw(@Valid PayoutForm form) { ... }

The review band is not a block

Most of the value is in what you do with review, and the common mistake is treating it as a soft block. It is the band where you do not have enough evidence to refuse and should buy some:

java
@Screen
@PostMapping("/checkout")
public String checkout(@Valid Order order, HttpServletRequest req) {
  var verdict = (Verdict) req.getAttribute("verdict");
  if (verdict != null && "review".equals(verdict.decision()) && fraud.isEnforcing()) {
    return stepUp(order);          // OTP, 3DS, manual queue
  }
  return charge(order);
}

A plain VPN lands in review on purpose. There is more on where to put each threshold in the risk score thresholds post.

Step 3: Submit your form once

Run the app on your computer or deploy it, open the page in an ordinary browser, wait a moment, and submit the form. The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and your log shows one maskbreak line. degraded=true means no network token arrived (the script was blocked, or the class is missing or on an input); device_evidence=not_sent means no browser check id arrived; device_evidence=ok with degraded=false is a complete check. When the lines look right after a few days, set MASKBREAK_MODE=enforce and redeploy. A fixed test token such as test_clean comes back with test: true and never passes a strict route.

Virtual threads, and why the timeout still matters

On Java 21 with spring.threads.virtual.enabled=true, blocking on this call no longer pins a platform thread, and the instinct is to conclude that timeouts stopped mattering. They matter more. Virtual threads make it cheap to accumulate thousands of requests all waiting on the same dead upstream, and the thing that runs out is not threads but connections and heap. The read timeout is what bounds that, and it is still not set by default.

If the upstream is properly down rather than slow, a circuit breaker (resilience4j-spring-boot3) saves you the queue entirely; make its fallback return null, the same "no opinion" the catch blocks return.

The short version

  • Script in the layout, the class on the <form>, three hidden fields forwarded as token, fingerprintEventId and tz.
  • Call every time, even without a token, and log one line. Watch mode never refuses.
  • Set the read timeout. There is no default, and virtual threads make an unbounded wait worse.
  • Annotate risk_score and device_evidence. A silent default is the worst failure mode in this integration.
  • 4xx is your bug and belongs at error; a timeout is weather and belongs at warn. Both mean "no opinion", never "safe".
  • In enforce mode, treat review as a step-up, not a soft block. A plain VPN is a customer.

Questions people ask

Is there an official Java or Spring Boot SDK?
No. There are official Node, Python and PHP SDKs; Java integrations use plain HTTP. For a single endpoint that is a RestClient bean and a record — under a hundred lines with the error handling — and it leaves the timeout, pool and fail-open policy as decisions you made rather than defaults you inherited.
Why does my Verdict record always show a risk score of zero?
The JSON field is risk_score and the record component is riskScore, so Jackson has nothing to bind and leaves the primitive at its default. Annotate the component with @JsonProperty("risk_score"). Do not fix it by switching the whole application to SNAKE_CASE, which silently renames every other DTO you own.
What timeout should a fraud check use in Spring Boot?
A few seconds at most for a user-facing action (the guide uses three), and you must set it explicitly because RestClient and WebClient both default to unbounded. Enabling virtual threads does not remove the need: it makes it cheap to pile up thousands of requests against a dead upstream, so the timeout is what bounds the damage.
Should the signup fail if the fraud API is down?
For signup and login, no — fail open, count the failure, and alert on the rate. A detection outage that takes registration down with it costs more than the abuse it would have caught. For irreversible money movement the trade flips: queue the payout for manual review rather than approving it blind. Either way the absent verdict means "no opinion", never "safe", and the calling code has to be the one that decides.

Paste it in, then watch the verdicts

The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.

Get started freeRead the API docs