On this page
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
bundle add vpndetection-railsThe 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.
# config/application.rb, inside class Application
config.vpndetection = { api_key: ENV["VPNDETECTION_API_KEY"] }# 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# config/routes.rb
Rails.application.routes.draw do
root "home#index"
get "addresses" => "home#addresses"
post "signup" => "signups#create"
post "checkout" => "checkouts#create"
endThe 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:
{
"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:
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:
# 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:
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:
# 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# 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# 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
endvpn?, 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). 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:
{
"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.


