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.
- Set
KORZEN_API_KEYin the environment and open the sign-up page. In the browser's developer tools, the_kzcookie appears after a few seconds. - Sign up. The decision shows on the dashboard's Decisions page, under the path you sent.
- 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-strategybehind 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-Foryourself. The visitor writes it, so a bot picks its own IP. - A new
RestClientorHttpClientfor each call. Each one opens its own connection, with a TLS handshake inside your timeout. - Calling
/v1/decidefrom 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.