A Spring Boot registration endpoint usually validates the form, checks that the email is unique, hashes the password and saves the user. None of those steps notices that the same laptop has already registered four accounts this week to collect four trials. A device check fills that gap without changing the rest of your stack.
This guide uses the Prynt Java SDK, a zero-dependency client for JDK 11+, plus Jackson, which Spring Boot already ships with. The approach matches the signup fraud basics: identify in the browser, verify on the server, link the account afterwards.
The browser side
On the registration page, identify the visitor and send the requestId with the form:
<script src="https://api.pryntid.com/cdn/prynt.umd.js"></script>
<script>
const ready = Prynt.load({ apiKey: 'pk_live_…' })
.then((a) => a.identify({ tag: { action: 'signup' } }))
.catch(() => null);
document.querySelector('#register').addEventListener('submit', async (e) => {
const r = await ready;
e.target.pryntRequestId.value = (r && r.requestId) || '';
});
</script>
If the form posts as a regular HTML submit, call e.preventDefault(), wait for ready, set the hidden field and call e.target.submit(). That’s the same pattern as the Rails guide.
A verdict service
Keep the Prynt logic out of the controller. A small service turns an event into one of four actions:
@Service
public class DeviceCheckService {
public enum Action { ALLOW, FLAG, VERIFY, BLOCK }
public record Verdict(Action action, String reason, String visitorId) {}
private final PryntServer prynt;
private final ObjectMapper mapper;
private final int maxAccountsPerDevice;
public DeviceCheckService(ObjectMapper mapper,
@Value("${prynt.secret-key}") String secretKey,
@Value("${prynt.max-accounts-per-device:2}") int max) {
this.prynt = new PryntServer(secretKey);
this.mapper = mapper;
this.maxAccountsPerDevice = max;
}
public Verdict check(String requestId) {
if (requestId == null || requestId.isBlank()) {
return new Verdict(Action.FLAG, "missing_request_id", null);
}
try {
JsonNode event = mapper.readTree(prynt.getEvent(requestId));
String visitorId = event.path("visitorId").asText(null);
String decision = event.path("decision").asText("allow");
int others = event.path("accountsOnDevice").path("count").asInt(0);
if ("block".equals(decision)) return new Verdict(Action.BLOCK, "prynt_block", visitorId);
if (!event.path("linkedId").isMissingNode() && !event.path("linkedId").isNull()) {
return new Verdict(Action.BLOCK, "request_id_reused", visitorId);
}
if (others >= maxAccountsPerDevice) {
return new Verdict(Action.VERIFY, "device_account_limit", visitorId);
}
if ("challenge".equals(decision)) return new Verdict(Action.FLAG, "prynt_challenge", visitorId);
return new Verdict(Action.ALLOW, null, visitorId);
} catch (PryntServer.PryntException e) {
return e.status == 404
? new Verdict(Action.FLAG, "event_not_found", null)
: new Verdict(Action.ALLOW, "prynt_unavailable", null); // fail open
} catch (Exception e) {
return new Verdict(Action.ALLOW, "prynt_unavailable", null);
}
}
}
What the service does with each part of the event:
getEventreturns the raw JSON ofGET /v1/events/{requestId}as a string. Parse it with whatever mapper you already use.decisionis a plain string on the server-side event:allow,challengeorblock. These come from Prynt’s risk engine and your console rules.accountsOnDevice.countis the number of your accounts (linkedIds) already seen on this device. Before the new user is linked, that’s the number of other accounts.- A non-null
linkedIdon the event means this exact identification was already used for another registration, a replay worth blocking. - A 404 means the id was never issued for your environment (forged, or from a test key in production). A 5xx or a timeout means Prynt is unreachable. Treating those two differently matters: the first is suspicious, the second is just an outage.
The device limit here returns VERIFY rather than BLOCK. For most B2C products, asking for a phone OTP or a card before unlocking the trial is a better default than a hard refusal. You can change the mapping once your data shows what the flagged accounts look like.
Mapping verdicts to HTTP
The controller stays thin:
@RestController
@RequestMapping("/api")
public class RegistrationController {
private final DeviceCheckService deviceCheck;
private final UserService users;
private final ApplicationEventPublisher events;
// constructor omitted
@PostMapping("/register")
@Transactional
public ResponseEntity<?> register(@Valid @RequestBody RegistrationForm form) {
var verdict = deviceCheck.check(form.pryntRequestId());
if (verdict.action() == DeviceCheckService.Action.BLOCK) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
"error", "signup_blocked",
"message", "We couldn't create an account from this device. Contact support if this is a mistake."));
}
User user = users.create(form, verdict.action().name(), verdict.reason(), verdict.visitorId());
events.publishEvent(new UserRegistered(user.getId(), form.pryntRequestId()));
boolean needsVerification = verdict.action() == DeviceCheckService.Action.VERIFY;
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("userId", user.getId(), "requireVerification", needsVerification));
}
}
| Verdict | HTTP | What the client does |
|---|---|---|
| ALLOW | 201 | Continue onboarding |
| FLAG | 201 | Nothing visible; account lands in a review queue |
| VERIFY | 201 + requireVerification: true | Show phone or card step before the trial |
| BLOCK | 403 | Show a neutral message with a support link |
Store the action, reason and visitorId on the user row. When support gets a “why can’t I sign up?” ticket, they have an answer, and analysts can join users by visitorId later.
Linking the user after commit
The user needs to be linked to the device so the next registration sees it. Doing that inside the transaction risks linking an account that then rolls back. An after-commit listener avoids it:
public record UserRegistered(long userId, String pryntRequestId) {}
@Component
public class PryntLinker {
private final PryntServer prynt;
private static final Logger log = LoggerFactory.getLogger(PryntLinker.class);
public PryntLinker(@Value("${prynt.secret-key}") String secretKey) {
this.prynt = new PryntServer(secretKey);
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void link(UserRegistered e) {
if (e.pryntRequestId() == null || e.pryntRequestId().isBlank()) return;
try {
prynt.updateEvent(e.pryntRequestId(), String.valueOf(e.userId())); // PUT /v1/events/{id}
} catch (Exception ex) {
log.warn("prynt link failed for user {}", e.userId(), ex);
}
}
}
@Async keeps the call off the request thread (enable it with @EnableAsync). The trade-off is a small window in which a very fast second registration from the same device won’t see the first one. If that matters for your product, drop @Async and the link finishes before the response returns.
Configuration
prynt.secret-key=${PRYNT_SECRET_KEY}
prynt.max-accounts-per-device=2
The secret key only sees its own environment, so a staging key can’t read production events. Keep it in your secret store, never in the frontend bundle. The public pk_ key is the only one that belongs in the page.
Testing it
PryntServer is a final class, so put a one-method interface (say, EventSource.fetch(requestId)) in front of it and inject that, so tests can swap in a stub that returns canned JSON. Cover four cases: a clean event, accountsOnDevice.count at the limit, decision: "block", and a thrown PryntException with status 500. Those four cases cover the whole decision table. The Go verification guide uses the same table-driven approach if you want a second reference.
Wrap-up
Put the logic in one service, keep the controller to a status mapping, link the user after commit, and start with VERIFY rather than BLOCK at the device limit. Device-based signup limits covers choosing the number. The other server SDKs are listed on the SDKs page.
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.