VPNDetectionVPNDetection

Java Spring Boot

The official Spring Boot starter for the VPNDetection API.

See on GitHub

Getting Started

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

Requires Java 17 or newer and Spring Boot 3 or 4.

You need an API key. Create one in the console; without one the allowance is counted per source address, and a server is a single source address, so a key is what makes this usable in production rather than optional.

# application.yml
vpndetection:
  api-key: ${VPNDETECTION_API_KEY}

If VPNDETECTION_API_KEY is unset, Spring leaves ${VPNDETECTION_API_KEY} as written. The starter then sends no key, so lookups use the free allowance, and a warning at startup names the variable. To run without a key on purpose, write ${VPNDETECTION_API_KEY:}.

The filter auto-configures itself. Read the answer anywhere downstream:

@GetMapping("/")
String index(HttpServletRequest request) {
    return VPNDetectionFilter.lookup(request)
            .flatMap(Lookup::result)
            .filter(Result::isVpn)
            .map(r -> "Hello, VPN user")
            .orElse("Hello");
}

By default nothing is blocked. Every request gets an answer and your own code decides what that means — which is usually what you want, because whether a VPN visitor is a problem depends entirely on what they are doing.

Blocking

Set a block-condition and a matching request is answered with 403 and never reaches your controllers.

vpndetection:
  api-key: ${VPNDETECTION_API_KEY}
  block-condition:
    - is_vpn: true

A condition is written in the shape of a served answer, keyed by the same names the API uses, and only the members you name are considered. That lets it reach the evidence, not just the flags:

block-condition:
  - is_vpn: true
    vpn: { provider: nordvpn }        # one provider
  - is_resproxy: true
    resproxy: { hits: { gte: 5 } }    # a numeric threshold

The list is OR: any one entry blocking is enough. Values are matched by equality, strings without regard to case. A list value means any-of. A map of gte/gt/lte/lt compares numbers, and every bound you give must hold, so two of them are a range. Members set to false or null are ignored, so a condition states the signals you act on; one that constrains nothing would match every request, and is refused when the context starts rather than silently blocking all your traffic.

In Java, build conditions with Bound.gte(5).andLt(100) and define your own Core bean — a typo in a bound is then a compile error rather than a silently ignored key.

Replace the refusal by defining your own VPNDetectionFilter bean; the auto-configuration backs off to it.

Where the client address comes from

This is the setting that decides whether any of the above works, and it is the one thing only you can get right.

By default the filter uses HttpServletRequest#getRemoteAddr, the socket peer. The servlet API has no trusted-proxy setting of its own. Behind nginx, a load balancer, or a CDN, every visitor arrives wearing your proxy's address, and that is the address that gets checked: VPN visitors pass unnoticed, and if it is a hosting address, a hosting rule blocks everyone. nginx on the same machine is 127.0.0.1 and a load balancer usually has a private address, which the filter answers locally without a lookup, flagging nobody and logging a warning.

Spring Boot's own answer is the one to reach for first, because it rewrites getRemoteAddr before this filter ever runs:

server:
  forward-headers-strategy: native

On Tomcat, Spring Boot's default server, native hands the header to Tomcat's RemoteIpValve. It believes X-Forwarded-For only when the connection comes from one of server.tomcat.remoteip.internal-proxies (every private and loopback address unless you narrow it), and reads the header from the right, so a visitor cannot choose their own address. Do not use framework for this: Spring's ForwardedHeaderFilter takes the left-most X-Forwarded-For entry from whoever connects, and the visitor writes that one, because nginx, AWS's Application Load Balancer and Cloudflare append to the header rather than replace it.

For an edge that writes the address into its own header, name it:

vpndetection:
  client-ip-header: CF-Connecting-IP

A callable cannot live in YAML, so anything more exotic — a chain depth, your own logic — means defining a Function<HttpServletRequest, String> bean named vpndetectionIpSelector. Every bean here is @ConditionalOnMissingBean, so yours replaces ours rather than colliding with it.

If the address resolves to a private one, the filter says so once. That is expected locally and is the signal to fix your configuration anywhere else.

When a lookup fails

The request is let through, and the reason is on lookup.error(). Our outage should not become yours, so a network failure, an exhausted quota or a rejected key all fail open. Set fail-closed: true to block instead. Private addresses are answered locally and never fail, so this will not lock you out in development.

Cost and latency

Answers are cached for an hour, so a returning visitor costs nothing, and private addresses never leave the process. A cache miss is one request to our API, bounded at 2500 ms by default and not retried — on a request path, failing open quickly beats holding a visitor while we try again.

Skip what you do not care about:

vpndetection:
  skip-paths: ["/actuator/**", "/static/**"]

Beyond a few million distinct visitors a day, stop calling the API per request: download the dataset and look addresses up locally instead.

Absent is not false

Without a key only ip and isVpn come back, and a free key adds Tor, relay, location() and asn(). A member your plan does not include is an empty Optional, which means "not in your plan" rather than "checked, and no".

result.isHosting().orElse(false)   // when you only want the flag

A block-condition naming a member your plan does not serve can never match, so the filter warns once instead of failing silently. Set on-missing-field: throw to make it an error.

On this page