# How to detect VPN users in Ruby on Rails

Add VPN detection to a Rails app with vpndetection-rails, and keep request.remote_ip from taking a forged X-Forwarded-For when no proxy sits in front.

This tutorial adds VPN detection to a Rails app with [`vpndetection-rails`](https://docs.vpndetection.io/integrations/rails), our official middleware. By the end, signup asks visitors on a VPN, a residential proxy or Tor to confirm their email, and checkout turns them away, each decided in its controller. Before either, it settles which address Rails hands the middleware: by default `request.remote_ip`, which takes a visitor's word for their own address whenever nothing sits in front of Rails. We ran every snippet against the live API on October 9, 2026, with Ruby 3.4.11, Rails 8.1.4, Puma 8.0.2, Rack 3.2.7 and `vpndetection-rails` 2.0.11, and every output below is from those runs.

## Look up every request

```bash
bundle add vpndetection-rails
```

The gem needs Ruby 3.3 or newer, and its Railtie inserts the middleware for you. Get an API key in the [console](https://app.vpndetection.io): 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.

```ruby
# config/application.rb, inside class Application
config.vpndetection = { api_key: ENV["VPNDETECTION_API_KEY"] }
```

```ruby
# app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    lookup = VPNDetection::Rails.lookup(request)
    render json: {
      ip: lookup.ip,
      result: lookup.result&.to_h,
      error: lookup.error&.message,
    }
  end

  def addresses
    render json: {
      request_ip: request.ip,
      remote_ip: request.remote_ip,
      looked_up: VPNDetection::Rails.lookup(request).ip,
    }
  end
end
```

```ruby
# config/routes.rb
Rails.application.routes.draw do
  root "home#index"
  get "addresses" => "home#addresses"
  post "signup" => "signups#create"
  post "checkout" => "checkouts#create"
end
```

The middleware looks every request up before it reaches a controller, and `VPNDetection::Rails.lookup(request)` reads the answer. Nothing is blocked. From `141.98.252.67`, a Mullvad server in London from [Mullvad's own server list](https://mullvad.net/en/servers), part of `result` read:

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

That's a key on the Max plan, and `to_h` is the API's answer as it came. A home broadband address on a US cable ISP came back with every flag `false` and every detail object empty. A private or reserved address never reaches the API: the middleware answers it in the process.

## Which address Rails looks up

The middleware asks Rails for `request.remote_ip`, the address `ActionDispatch::RemoteIp` works out from `X-Forwarded-For`. We put nginx 1.29.1 in front of Puma in a container of its own, on a private network, 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 Puma. `/addresses` answered:

| | Through nginx | Straight to Puma |
|---|---|---|
| `request.remote_ip`, looked up by default | `141.98.252.67` | `1.1.1.1` |
| `request.ip` | `141.98.252.67` | `141.98.252.67` |

Behind nginx, Rails gets it right. With nothing in front, the visitor picks the address the middleware looks up, and `1.1.1.1` answers `is_hosting` but not `is_vpn`, so a VPN check waves them through. Rails says so in its own source, at the top of [`remote_ip.rb`](https://github.com/rails/rails/blob/v8.1.4/actionpack/lib/action_dispatch/middleware/remote_ip.rb):

> IF YOU DON'T USE A PROXY, THIS MAKES YOU VULNERABLE TO IP SPOOFING.

`remote_ip` drops the addresses it trusts, private and loopback ranges by default, from the header and the connecting address, and takes the right-most one left, without asking whether the connection came from a proxy at all. Rack's `request.ip` reads the header only when the connection comes from a private or loopback address, as nginx's did, and otherwise returns the connecting address. So when Puma faces the internet directly, hand the middleware Rack's address instead:

```ruby
# config/application.rb, inside class Application
config.vpndetection = {
  api_key: ENV["VPNDETECTION_API_KEY"],
  ip_selector: ->(request) { request.ip },
}
```

With that, the visitor straight to Puma was looked up as `141.98.252.67` with or without the forged header, and through nginx it was still `141.98.252.67`.

Behind a CDN, the connecting address is the CDN. From a Cloudflare address, the first app looked up that address and found `is_cdn: true`. Read the header the CDN writes instead:

```ruby
config.vpndetection = {
  api_key: ENV["VPNDETECTION_API_KEY"],
  ip_selector: VPNDetection::Rails.header_ip_selector("CF-Connecting-IP"),
}
```

From Cloudflare's address, `CF-Connecting-IP: 141.98.252.67` came back as the VPN, and with the header missing, the selector fell back to the connecting address. Straight to Puma, `CF-Connecting-IP: 1.1.1.1` was looked up as `1.1.1.1`, so let only [Cloudflare's ranges](https://www.cloudflare.com/ips/) reach your origin.

## Decide per action

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

```ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  private

  # A VPN, residential proxy or Tor visitor, or one we know nothing about.
  def masked?
    result = VPNDetection::Rails.lookup(request)&.result
    result.nil? || result.vpn? || result.resproxy? || result.tor?
  end
end
```

```ruby
# app/controllers/signups_controller.rb
class SignupsController < ApplicationController
  # Nobody is refused, but a masked visitor confirms their email first.
  def create
    render json: { confirm_email: masked? }
  end
end
```

```ruby
# app/controllers/checkouts_controller.rb
class CheckoutsController < ApplicationController
  before_action :refuse_masked

  def create
    render json: { ordered: true }
  end

  private

  # A VPN, Tor, or a residential proxy exit seen 5 times or more.
  def refuse_masked
    result = VPNDetection::Rails.lookup(request)&.result
    return if result.nil?

    hits = result.resproxy&.fetch("hits", 0).to_i
    return unless result.vpn? || result.tor? || (result.resproxy? && hits >= 5)

    render json: { error: "Please turn off your VPN or proxy to check out." },
           status: :forbidden
  end
end
```

`vpn?`, `resproxy?` and `tor?` are `true` or `false`, never `nil`, so a flag your plan doesn't include reads as `false`. `masked?` also answers `true` when the lookup failed, since then you know nothing about the visitor. The demo's controllers inherit from `ActionController::API`, so curl gets past the CSRF check and Rails 8's `allow_browser`; in an app with forms, the same methods go in the controllers you have.

We sent both actions the Mullvad server, the home address, and two residential proxy exits from our data, one with `hits` at 27 and one at 2:

| Visitor | `POST /signup` | `POST /checkout` |
|---|---|---|
| Mullvad server | `{"confirm_email":true}` | 403 |
| Home broadband | `{"confirm_email":false}` | 200 |
| Residential proxy exit, `hits` 27 | `{"confirm_email":true}` | 403 |
| Residential proxy exit, `hits` 2 | `{"confirm_email":true}` | 200 |

Each 403 came with `{"error":"Please turn off your VPN or proxy to check out."}`. To refuse a condition on every request instead, the middleware takes a `block_condition` in `config.vpndetection`, written in the shape of the answer, such as `{ is_vpn: true }`.

## When a lookup fails

The middleware fails open: when our API is unreachable, your quota is spent or the key is wrong, the request goes on with `result` as `nil` and the reason in `error`. With an invalid key, the first app's `error` was `invalid API key`, signup asked the Mullvad visitor for their email, and checkout took their order. `fail_closed: true` in the config refuses instead, 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.

Each Puma process caches answers for an hour, and private and reserved addresses never leave it. A miss holds the visitor for one request of at most 2.5 seconds. Assets cost a lookup each, like any other path, unless a `skip` in the config leaves them out.

A free key covers 50,000 lookups a month, and paid plans start at 250,000 ([pricing](https://vpndetection.io/pricing)). With a few million distinct visitors a day, download the [VPN IP database](https://vpndetection.io/databases/vpn-ip) and look addresses up locally instead of calling the [API](https://vpndetection.io/api).

## Absent is not false

Without a key, the Mullvad request from the first section answers:

```json
{
  "ip": "141.98.252.67",
  "is_vpn": true
}
```

Everything else is missing, which means "not in your plan", not "checked, and no". `is_vpn` is on every plan, the hosting, Tor, relay and CDN flags start on Starter, the proxy flags on Scale, and `confidence`, `method` and `resproxy.hits` are on Max. In Ruby, `result.is_hosting` is `nil` for a flag your plan doesn't include, while `result.hosting?` turns that into `false`, and `result.included?(:is_hosting)` says which it was.

So without a key, checkout still refused the Mullvad server on `vpn?`, and took the order from the proxy exit with 27 hits.

The same setup in Django is in [How to detect VPN users in Django](https://vpndetection.io/blog/detect-vpn-users-django).

