Korzen
Bot Protection

Spring Boot

Bot Protection in Spring Boot: the tag, one component for the call with RestClient under one timeout, Tomcat's forwarded headers for the visitor's IP, and the controller.

This guide is for Spring Boot 3.2 and later (Spring MVC on Tomcat, Java 17 or later). The call uses Spring's RestClient on the JDK's own HttpClient, one for the process, which keeps the connection open; its read timeout bounds the whole call, from sending the request to its answer.

The tag

Put the tag in the <head> of the layout your sign-up, sign-in and checkout pages use (with Thymeleaf, the fragment they share):

<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

The call

Read the key from the environment, and let Tomcat read your proxy's forwarded headers:

# src/main/resources/application.properties
korzen.api-key=${KORZEN_API_KEY}
server.forward-headers-strategy=native
// src/main/java/com/example/Korzen.java
package com.example;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.WebUtils;

@Component
public class Korzen {
  /** The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. */
  private static final List<String> FORWARDED = List.of("accept", "accept-encoding", "accept-language", "sec-ch-ua",
      "sec-ch-ua-mobile", "sec-ch-ua-platform", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "upgrade-insecure-requests");
  private static final Duration TIMEOUT = Duration.ofMillis(200);

  private final RestClient client; // one per process: it keeps the connection open

  public Korzen(@Value("${korzen.api-key}") String apiKey) {
    var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder().connectTimeout(TIMEOUT).build());
    factory.setReadTimeout(TIMEOUT); // the whole call, from the request to its answer
    client = RestClient.builder()
        .baseUrl("https://korzen.dev")
        .requestFactory(factory)
        .defaultHeader("Authorization", "Bearer " + apiKey)
        .defaultHeader("Korzen-Deadline-Ms", "200")
        .build();
  }

  /** The answer for this request, or null on any error: then go on as on allow (fail open). */
  public Map<String, Object> decide(HttpServletRequest request, String path) {
    var headers = new HashMap<String, String>();
    for (var name : FORWARDED) {
      var value = request.getHeader(name);
      if (value != null) {
        headers.put(name, value);
      }
    }
    Cookie token = WebUtils.getCookie(request, "_kz");
    var body = new HashMap<String, Object>(); // a HashMap, since Map.of takes no null
    body.put("ip", request.getRemoteAddr()); // see "The visitor's IP"
    body.put("user_agent", request.getHeader("User-Agent"));
    body.put("headers", headers);
    body.put("token", token == null ? null : token.getValue());
    body.put("path", path);
    body.put("method", "POST");
    try {
      return client.post().uri("/v1/decide").contentType(MediaType.APPLICATION_JSON).body(body)
          .retrieve().body(new ParameterizedTypeReference<Map<String, Object>>() {});
    } catch (RuntimeException e) {
      return null; // a timeout, a network error or any status but 200
    }
  }
}

A servlet container keeps no order of the request's headers, so none is sent. Then, in the controller that creates the account:

// src/main/java/com/example/SignupController.java
package com.example;

import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SignupController {
  private final Korzen korzen;

  public SignupController(Korzen korzen) {
    this.korzen = korzen;
  }

  @PostMapping("/signup")
  public ResponseEntity<String> signup(HttpServletRequest request, @RequestParam String email) {
    Map<String, Object> d = korzen.decide(request, "/signup");
    if (d != null && "block".equals(d.get("action"))) {
      return ResponseEntity.badRequest().body("We could not create your account.");
    }
    // "challenge": your CAPTCHA, or an email or phone check, before going on
    var user = accounts.create(email); // your own code
    user.setKorzenRequestId(d == null ? null : (String) d.get("request_id")); // see "Keep the request_id"
    return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/welcome")).build();
  }
}

The visitor's IP

request.getRemoteAddr() is the connection's address, your proxy's if you have one. server.forward-headers-strategy=native turns on Tomcat's RemoteIpValve: from a proxy on a private network (10.x, 172.16–31.x, 192.168.x, loopback), it takes the last entry of X-Forwarded-For that no such proxy added, and getRemoteAddr() returns it. A proxy on a public address is not trusted until you name it in server.tomcat.remoteip.internal-proxies. Behind Cloudflare, read request.getHeader("CF-Connecting-IP") instead, if your server only takes traffic from Cloudflare.

Apply the action

Read only action. On block, answer with the same error the action shows when it fails for any other reason, so a bot learns nothing. On challenge, use the check you already have (a CAPTCHA, an email or phone code); with none, go on. The account starts in monitor mode, where action is always allow: nothing changes for visitors until you turn on block mode.

Keep the request_id

Every answer has a request_id. Keep it with what the action created, in a column of the account or the order, or in a log line with their id. It names that decision when you ask us about it, and lets you find out later which answer let a fake account in.

Test it on your machine

Run ./mvnw spring-boot:run (or ./gradlew bootRun) and open it on localhost. Pages on localhost always accept the tag and are never listed as a domain. Locally the IP is 127.0.0.1 or 0:0:0:0:0:0:0:1, and that is fine.

  1. Set KORZEN_API_KEY in the environment and open the sign-up page. In the browser's developer tools, the _kz cookie appears after a few seconds.
  2. Sign up. The decision shows on the dashboard's Decisions page, under the path you sent.
  3. Check fail open: set a wrong key and sign up again. The call answers 401, and the sign-up goes through.

Common mistakes

  • No server.forward-headers-strategy behind a load balancer. Every visitor then has the balancer's private IP, which Bot Protection takes for no IP: it sees much less, and the Set-up page warns you.
  • Reading the first entry of X-Forwarded-For yourself. The visitor writes it, so a bot picks its own IP.
  • A new RestClient or HttpClient for each call. Each one opens its own connection, with a TLS handshake inside your timeout.
  • Calling /v1/decide from the browser. The API key would reach every visitor; the call belongs on your server.
  • No timeout, or an error that stops the sign-up. Any answer other than 200, and no answer in time, means: let the visitor through.