Skip to content
VPNDetection

How to detect VPN users in Spring Boot

Add VPN detection to a Spring Boot app with the VPNDetection starter: forward-headers-strategy native, never framework, then check signup and checkout.

Published 6 min read

On this page
  1. Look up every request
  2. Use native forwarded headers
  3. Decide signup and checkout
  4. When a lookup fails
  5. Absent is not false

This tutorial adds VPN detection to a Spring Boot app with vpndetection-spring-boot-starter, our official starter. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, and checkout turns them away. Before either works, server.forward-headers-strategy has to be native: the servlet API has no trusted-proxy setting, and both framework and a hand-written filter that takes the header's first entry look up the X-Forwarded-For entry the visitor writes. We ran every snippet against the live API on October 21, 2026, with Java 25.0.4.1, Spring Boot 4.1.1 and the starter 3.0.17, and every output below is from those runs.

Look up every request

Start from a Spring Initializr project with Spring Web, and add the starter to pom.xml:

xml
<dependency>
    <groupId>io.vpndetection</groupId>
    <artifactId>vpndetection-spring-boot-starter</artifactId>
    <version>3.0.17</version>
</dependency>

The starter needs Java 17 or newer and Spring Boot 3 or 4. Get an API key in the console: a free one covers 50,000 lookups a month. Without a key, the API allows 1,000 lookups a day per source address, and once deployed, every lookup comes from your server's address.

yaml
# src/main/resources/application.yml
vpndetection:
  api-key: ${VPNDETECTION_API_KEY}

Set VPNDETECTION_API_KEY wherever the app runs. Left unset, Spring keeps ${VPNDETECTION_API_KEY} as written, and the starter sends no key: lookups get what the API answers without one, and startup logs one warning naming the variable:

vpndetection: api-key is ${VPNDETECTION_API_KEY}, a placeholder nothing resolved, so lookups go without a key, on the free allowance. Set the variable it names, or give it an empty default (${VPNDETECTION_API_KEY:}) to run without a key on purpose

To run without a key on purpose, write ${VPNDETECTION_API_KEY:}, with the colon: it sends no key and logs nothing.

The starter registers its filter itself. VPNDetectionFilter.lookup() reads the answer in a controller:

java
package com.example.demo;

import io.vpndetection.Result;
import io.vpndetection.middleware.Lookup;
import io.vpndetection.spring.VPNDetectionFilter;
import jakarta.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class VisitorController {
    @GetMapping("/")
    Map<String, Object> index(HttpServletRequest request) {
        Lookup lookup = VPNDetectionFilter.lookup(request).orElseThrow();
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("ip", lookup.ip().orElse(null));
        body.put("result", lookup.result().map(Result::raw).orElse(null));
        body.put("error", lookup.error().map(Exception::getMessage).orElse(null));
        return body;
    }
}

Nothing is blocked: the filter looks the visitor up and hands the answer on. We sent a request to this app from 193.32.248.66, a Mullvad server in Berlin from Mullvad's own server list. Part of what came back under result:

json
{
  "is_vpn": true,
  "is_hosting": true,
  "is_tor": false,
  "is_resproxy": false,
  "vpn": {
    "provider": "mullvad",
    "last_seen": "2026-10-20",
    "confidence": "high"
  }
}

That's a key on the Max plan. The answer also carries is_relay, is_cdn, is_dcproxy and is_mobproxy, each with a detail object, and location and asn: Germany and AS39351 here. In code, read the typed methods, result.isVpn() and result.vpn(). A home broadband address on a US cable ISP came back with every flag false. A private or reserved address never reaches the API: the starter answers it itself, with isBogon() set.

Use native forwarded headers

The filter looks up getRemoteAddr(), the address of whatever connected. Behind a load balancer, a CDN or nginx, that's the proxy. Spring Boot has two ways to read the visitor from X-Forwarded-For instead, chosen by server.forward-headers-strategy:

yaml
server:
  forward-headers-strategy: native
vpndetection:
  api-key: ${VPNDETECTION_API_KEY}

framework installs Spring's ForwardedHeaderFilter, and native hands the header to the server, on Tomcat its RemoteIpValve. The third way is a filter of your own that takes the header's first entry:

java
// The usual hand-rolled client address: the first X-Forwarded-For entry.
@Bean
Function<HttpServletRequest, String> vpndetectionIpSelector() {
    return request -> {
        String xff = request.getHeader("X-Forwarded-For");
        return xff == null ? request.getRemoteAddr() : xff.split(",")[0].trim();
    };
}

A bean named vpndetectionIpSelector replaces the starter's choice of address, so that's how we ran it. We put nginx 1.29.1 in front of the app, in a container of its own, at 172.28.16.10, with the usual proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for. A visitor on the Mullvad server sent X-Forwarded-For: 1.1.1.1, through nginx and then straight to the app. The address the filter looked up:

Client address fromThrough nginxStraight to the app
nothing set172.28.16.10193.32.248.66
framework1.1.1.11.1.1.1
split(",")[0]1.1.1.11.1.1.1
native193.32.248.66193.32.248.66

With nothing set, every visitor through nginx is nginx, a private address the starter answers itself, so nobody is ever flagged. The one sign is a warning in the log, once:

resolved the client address as 172.28.16.10, which is not a public address. If this application runs behind a proxy or load balancer, configure its trusted-proxy setting or pass an ipSelector that reads your edge's header.

framework and the hand-written filter both take the left-most entry from whoever connects, and nginx, like most proxies, appends to the header rather than replacing it, so the left-most entry is the one the visitor sent. 1.1.1.1 answers is_hosting and is_cdn, not is_vpn, so a VPN check waves them through.

native believes X-Forwarded-For only from an address in server.tomcat.remoteip.internal-proxies, every private and loopback address unless you narrow it, and reads the header from the right. A visitor connecting from the internet isn't a proxy, so their header is ignored. An AWS load balancer sends requests to your servers from private addresses, which the default trusts. On Kubernetes, Cloud Foundry and the other platforms Spring Boot detects, native is already the default.

Behind Cloudflare, native gave us a Cloudflare address, which answered is_cdn: true: Cloudflare connects from public addresses, which aren't internal proxies. The starter can read Cloudflare's own header instead:

yaml
vpndetection:
  api-key: ${VPNDETECTION_API_KEY}
  client-ip-header: CF-Connecting-IP

From a Cloudflare address, with the headers Cloudflare adds for the Mullvad visitor, that looked up 193.32.248.66. Straight to the app, with CF-Connecting-IP: 1.1.1.1, it looked up 1.1.1.1: the starter reads the header from whoever sends it. Use it only where nothing but Cloudflare can reach the app.

Decide signup and checkout

Most routes don't care who's on a VPN; signup and checkout do. The filter has already looked the visitor up, so each of those routes only has to decide:

java
package com.example.demo;

import io.vpndetection.middleware.Bound;
import io.vpndetection.middleware.Condition;
import io.vpndetection.middleware.Lookup;
import io.vpndetection.spring.VPNDetectionFilter;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class ShopController {
    /** Refused at checkout: a VPN, Tor, or a proxy exit seen 5 times or more. */
    private static final List<Map<String, Object>> REFUSE = List.of(
            Map.of("is_vpn", true),
            Map.of("is_tor", true),
            Map.of("is_resproxy", true, "resproxy", Map.of("hits", Bound.gte(5))));

    // Nobody is refused, but a masked visitor confirms their email first.
    @PostMapping("/signup")
    Map<String, Boolean> signup(HttpServletRequest request) {
        boolean masked = VPNDetectionFilter.lookup(request)
                .flatMap(Lookup::result)
                .map(r -> r.isVpn()
                        || r.isResproxy().orElse(false)
                        || r.isTor().orElse(false))
                .orElse(true);
        return Map.of("confirm_email", masked);
    }

    @PostMapping("/checkout")
    ResponseEntity<Map<String, Object>> checkout(HttpServletRequest request) {
        boolean refuse = VPNDetectionFilter.lookup(request)
                .flatMap(Lookup::result)
                .map(r -> Condition.matches(REFUSE, r))
                .orElse(false);
        if (refuse) {
            String error = "Please turn off your VPN or proxy to check out.";
            return ResponseEntity.status(403).body(Map.of("error", error));
        }
        return ResponseEntity.ok(Map.of("ordered", true));
    }
}

When the lookup failed there's no result, and then you know nothing about the visitor: signup asks for their email, and checkout takes the order. isResproxy() and isTor() return an Optional, empty when your plan doesn't include the field, as "Absent is not false" below explains.

We sent both routes the Mullvad server, the home address, and two residential proxy exits from our data, one with hits at 25 and one at 1, all through nginx with native:

VisitorPOST /signupPOST /checkout
Mullvad server{"confirm_email":true}403
Home broadband{"confirm_email":false}200
Residential proxy exit, hits 25{"confirm_email":true}403
Residential proxy exit, hits 1{"confirm_email":true}200

Each 403 came with {"error":"Please turn off your VPN or proxy to check out."}.

Condition.matches() is the matcher behind the starter's own block-condition, keyed by the names the API uses. Each map in the list is one way to match, and it names only the fields it cares about. A list as a value accepts any of its values, Bound.gte(), gt(), lte() and lt() compare numbers, and strings match without regard to case.

To refuse on every route, give the starter the condition instead:

yaml
server:
  forward-headers-strategy: native
vpndetection:
  api-key: ${VPNDETECTION_API_KEY}
  block-condition:
    - is_vpn: true
    - is_tor: true

The Mullvad server then got a 403 with {"error":"access denied"} on every route, one that doesn't exist included, and the home address got through.

When a lookup fails

The starter fails open: when our API is unreachable, your quota is spent or the key is wrong, the request goes on with no result and the reason in error(). With an invalid key, the first controller answered:

json
{"ip":"193.32.248.66","result":null,"error":"invalid API key"}

With the same key, signup asked the Mullvad visitor for their email, and checkout took their order. fail-closed: true refuses every request whose lookup failed, on every route, which makes any outage of ours an outage of your app. Fail open, and ask more of a visitor whose lookup failed, as signup does.

The starter keeps answers for an hour in the application's memory, which lasts as long as the JVM. Three requests in a row from the Mullvad server made one call to the API. A miss holds the visitor for one request of at most 2.5 seconds.

A free key covers 50,000 lookups a month, and paid plans start at 250,000 (pricing). With a few million visitors a day, download the VPN IP database and look addresses up locally instead of calling the API.

Absent is not false

Without a key, through ${VPNDETECTION_API_KEY:}, the Mullvad request from the first section came back with is_vpn true and every other field null. result.isHosting(), isTor() and isResproxy() are then empty, which means "not in your plan", where Optional.of(false) means "checked, and no". is_vpn comes back without a key, a free key adds the Tor and relay flags, location and asn, and every paid plan answers every field.

So without a key, checkout still refused the Mullvad server on is_vpn, and the proxy exit with 25 hits got through both routes: signup didn't ask for its email, and checkout took its order. With is_tor in its block-condition, the starter logged once:

blockCondition names is_tor, which your plan does not include, so those terms can never match. An absent member means "not in your plan", not "checked, and no".

The same Java SDK runs inside a game server in Block VPN players on Minecraft: a Velocity and Paper plugin.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Try it on your own traffic.

The detection these posts describe is one API call away, with SDKs for every major language.

00 · No key

Start without signing up.

Paste it into a terminal. No account needed. 1,000 lookups a day per address, answering ip and is_vpn.

curl "https://api.vpndetection.io/146.70.116.130"
{
"ip": "146.70.116.130",
"is_vpn": true
}
01 · Free key

Sign up and get 50,000 requests a month.

No card and no sales call: the key is issued on signup.
Every request flags VPN IPs.
Upgrade only when you outgrow the allowance.