How to Detect a User's Country from IP Address: A Backend Guide

Detecting the country from a visitor's IP address — when it works, when it doesn't, and the production-ready patterns in every major backend language.

How to Detect a User's Country from IP Address: A Backend Guide

The most common use of IP geolocation is the simplest one: figuring out which country a visitor is in. Currency display, language hints, regulatory copy, basic geofencing — all of these need just two letters. Once you have the country code, dozens of small product decisions become easy.

This post is the focused guide. Just the country layer. The patterns that work in production, the failure modes, and code samples in every major backend language.

The Core Idea

Every public IP address has been allocated by a Regional Internet Registry to a specific entity in a specific country. Most geolocation databases derive country-level data from these registry allocations plus a handful of additional signals (BGP announcements, probing). The result: country-level accuracy is generally 99%+ for residential and business IPs in well-mapped markets.

This is the most reliable layer of IP geolocation. If you only need the country, you have an easy job. Where things get tricky is mobile IPs (CGNAT gateway in a different country), VPNs (intentional rerouting), and a small fraction of weird edge cases. See IP Geolocation Accuracy for the full discussion.

The Three Ways to Detect Country

Send the IP, get back the country code. One HTTP call.

GET https://api.ip2geo.dev/convert?ip=8.8.8.8
X-Api-Key: <your key>

{
  "data": {
    "ip": "8.8.8.8",
    "continent": {
      "country": {
        "code": "US",
        "name": "United States"
      }
    }
  }
}

2. CDN-injected header

If you’re behind Cloudflare, AWS CloudFront, or another major CDN, the CDN already does the lookup and injects a header into your request.

CF-IPCountry: US                  (Cloudflare)
CloudFront-Viewer-Country: US     (CloudFront)

Your backend just reads the header. Zero added latency, no API call.

3. Offline database (low-latency, more operational overhead)

Local .mmdb file (typically GeoLite2 or commercial GeoIP2). Lookup is sub-millisecond and offline. See hidden costs of GeoLite2 for the trade-offs.

For most apps, the right answer is CDN header where available, hosted API otherwise. Offline databases are a niche choice for latency-critical paths.

Implementation in Each Major Language

Node.js / TypeScript

// Express + Cloudflare
app.get('/api/whatever', (req, res) => {
    const country = req.headers['cf-ipcountry'] as string ?? 'unknown'
    res.json({ country })
})

// Express + Ip2Geo API
import { convertIP, init } from '@ip2geo/sdk'
init({ authKey: process.env.IP2GEO_API_KEY! })

app.get('/api/whatever', async (req, res) => {
    const result = await convertIP(req.ip)
    const country = result.success ? result.data.continent.country.code : 'unknown'
    res.json({ country })
})

See the full Node.js guide for middleware patterns, caching, and edge functions.

Python

# FastAPI + Cloudflare
from fastapi import Request

@app.get('/whatever')
async def handler(request: Request):
    country = request.headers.get('cf-ipcountry', 'unknown')
    return {'country': country}

# FastAPI + Ip2Geo SDK
import ip2geo
ip2geo.init(os.environ['IP2GEO_API_KEY'])

@app.get('/whatever')
async def handler(request: Request):
    ip = request.client.host
    result = await asyncio.to_thread(ip2geo.convert_ip, ip=ip)
    country = result['data']['continent']['country']['code'] if result['success'] else 'unknown'
    return {'country': country}

See the full Python guide for caching, Django, async patterns.

PHP

// Laravel + Cloudflare
public function handler(Request $request) {
    $country = $request->header('CF-IPCountry', 'unknown');
    return response()->json(['country' => $country]);
}

// Laravel + Ip2Geo SDK
use Ip2Geo\Ip2Geo;

public function handler(Request $request) {
    Ip2Geo::init(config('services.ip2geo.key'));
    $result = Ip2Geo::convertIp($request->ip());
    $country = $result['data']['continent']['country']['code'] ?? 'unknown';
    return response()->json(['country' => $country]);
}

See the full PHP guide.

Go

// Reading Cloudflare header
country := r.Header.Get("CF-IPCountry")
if country == "" { country = "unknown" }

// Calling the API
url := fmt.Sprintf("https://api.ip2geo.dev/convert?ip=%s", ip)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("X-Api-Key", os.Getenv("IP2GEO_API_KEY"))
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var data struct {
    Data struct {
        Continent struct {
            Country struct{ Code string }
        }
    }
}
json.NewDecoder(resp.Body).Decode(&data)
country := data.Data.Continent.Country.Code

Ruby

# Rails + Cloudflare
class ApplicationController < ActionController::Base
  before_action :set_country
  
  def set_country
    @country = request.headers['CF-IPCountry'] || 'unknown'
  end
end

# Rails + Ip2Geo API via HTTP
require 'net/http'
require 'json'

def country_from_ip(ip)
  uri = URI("https://api.ip2geo.dev/convert?ip=#{ip}")
  req = Net::HTTP::Get.new(uri, 'X-Api-Key' => ENV['IP2GEO_API_KEY'])
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
  JSON.parse(res.body).dig('data', 'continent', 'country', 'code') || 'unknown'
end

Rust / actix-web

async fn handler(req: HttpRequest) -> impl Responder {
    let country = req.headers()
        .get("cf-ipcountry")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("unknown");
    
    HttpResponse::Ok().json(serde_json::json!({ "country": country }))
}

Getting the Real Client IP

In every framework, the default “client IP” is whoever directly connected to your server. Behind a load balancer or CDN, that’s the proxy — not the user. You must read forwarded headers:

X-Forwarded-For: <user_ip>, <proxy_1>, <proxy_2>

The leftmost IP is the original client. Only trust this when you control the proxy — otherwise users can spoof the header.

Frameworks usually have built-in support if you tell them how many proxies to trust:

  • Express: app.set('trust proxy', 1) (one proxy in front)
  • Laravel: App\Http\Middleware\TrustProxies configuration
  • FastAPI: Use request.client.host after Uvicorn --proxy-headers
  • Rails: config.action_dispatch.trusted_proxies

If you’re behind Cloudflare specifically, use CF-Connecting-IP for an even more trustworthy source.

Country Code Conventions

You’ll see countries represented as:

  • ISO 3166-1 alpha-2: Two-letter codes (US, DE, JP). The most common form, used in HTML language tags, currency mapping, etc.
  • ISO 3166-1 alpha-3: Three-letter codes (USA, DEU, JPN). Less common.
  • ISO 3166-1 numeric: Three-digit codes (840, 276, 392). Rare in web work.
  • English country names: Variable; use the codes instead.

All major geolocation providers return ISO 3166-1 alpha-2 by default. The Cloudflare header also returns alpha-2. Standardize on alpha-2 throughout your codebase.

Special Cases

A few country codes you’ll see that don’t represent normal countries:

  • T1 — Cloudflare’s code for Tor exit nodes (their nonstandard extension).
  • XX — Some providers use this for “unknown.”
  • EU — Some providers use this for IPs in the European Union without a specific country.
  • AP — Asia-Pacific (rare; for IPs without a specific country mapping in that region).

Handle these in your code. Don’t crash when the country isn’t a standard ISO code.

Caching for Performance

Even though country lookups via API are fast (<50ms typically), at scale you want to cache. The country for an IP is essentially fixed — cache for at least 5 minutes, often longer.

const countryCache = new LRUCache<string, string>({ max: 100_000, ttl: 3600_000 })

async function getCountry(ip: string): Promise<string> {
    const cached = countryCache.get(ip)
    if (cached) return cached
    
    const result = await convertIP(ip)
    const country = result.success ? result.data.continent.country.code : 'unknown'
    countryCache.set(ip, country)
    return country
}

For multi-instance services, use Redis. See caching strategies.

Common Pitfalls

1. Hard-blocking by country

Country mismatches happen — travel, VPNs, mobile carriers exit in unexpected places. Don’t lock accounts because the IP country doesn’t match the billing country. Use country as one input among several; see geofencing 101.

2. Assuming the country exists

Some IPs resolve to no country at all (anycast addresses, certain satellite ISPs). Your code must handle country === undefined.

3. Not handling case

US and us are the same country. Standardize to uppercase before comparison.

4. Using country names instead of codes

“United States” can be returned as “USA”, “United States of America”, “US”… codes are stable; names aren’t.

5. Localizing the country name in the wrong place

The country code (US) is a stable identifier. The country name depends on the user’s language. Look up the localized name in your i18n system, not from the geo API.

The “Just Use Cloudflare” Take

For most production web applications in 2026, the simplest correct path is:

  1. Put Cloudflare in front of your app.
  2. Read the CF-IPCountry header in your backend.
  3. Done.

This is free (with any Cloudflare plan including the free one), zero-latency (the header is already on the request), and handles caching globally. The downside: you’re locked into Cloudflare’s data and don’t get richer fields (ASN, city, VPN detection).

For apps that need more than country — city for personalization, ASN for fraud detection, or VPN detection for security — use a hosted API alongside or instead.

TL;DR

  • Country-level IP geolocation is reliable (~99% accurate) for normal traffic.
  • CDN headers are the simplest source if you’re already behind one.
  • A hosted API is the simplest source otherwise — one HTTP call, predictable cost.
  • Read forwarded headers correctly so your code gets the user’s IP, not the proxy’s.
  • Cache aggressively. Country data is stable; high TTLs are fine.
  • Standardize on ISO 3166-1 alpha-2 codes throughout your code.
  • Handle unknown countries gracefully. Don’t crash on edge cases.

The country code is the easiest, cheapest, most reliable signal in the IP geolocation toolkit. For the bigger picture (city, ASN, all the other fields), see How to Geolocate an IP Address. To try it on real traffic, Ip2Geo’s free tier gives you 1,000 lookups/month including country and everything else.

Get Started

Convert IPs into accurate location data in milliseconds.

Sign up today and get 1,000 free monthly stored conversions, and discover why developers trust us for fast, reliable, and affordable IP conversions.