Skip to content
VPNDetection

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.

Published 4 min read

On this page
  1. Look up every request
  2. Which address Rails looks up
  3. Decide per action
  4. When a lookup fails
  5. Absent is not false

This tutorial adds VPN detection to a Rails app with vpndetection-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: 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, 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 nginxStraight to Puma
request.remote_ip, looked up by default141.98.252.671.1.1.1
request.ip141.98.252.67141.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:

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 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:

VisitorPOST /signupPOST /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). With a few million distinct 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, 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.

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. 1k daily allowance per user, answering ip and is_vpn.

curl "https://api.vpndetection.io/45.83.91.1"
{
"ip": "45.83.91.1",
"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.