ScreenshotNeo

BlogHow-to

IP Geolocation Using Python Flask (2026)

Build privacy-aware IP geolocation in Flask with proxy-safe address handling, hosted APIs, local GeoIP databases, errors, and production guidance.

By the ScreenshotNeo team1 October 20269 min read

Direct answer: In Flask, read the request’s apparent remote address, validate it with Python’s ipaddress module, then perform a server-side lookup against a hosted provider or a local GeoIP database. If your app runs behind a reverse proxy, configure Werkzeug’s ProxyFix for the exact number of trusted proxies; never trust arbitrary client-supplied forwarding headers. Treat the result as an approximate region signal, not a precise address or verified identity.

1. Understand the request path

A browser does not automatically give your Flask code a trustworthy client IP. Flask sees the address of the peer connected to the WSGI server. With a direct connection, that is usually the client address. With a load balancer, CDN or ingress proxy, it may be the proxy’s address.

Flask’s deployment documentation explains: “When using a reverse proxy, or many Python hosting platforms, the proxy will intercept and forward all external requests to the local WSGI server.” Read the proxy deployment guidance and Flask API reference for your topology.

  1. The edge proxy accepts the public request.
  2. Your proxy overwrites forwarding headers with values from the connection it accepted.
  3. Werkzeug’s ProxyFix replaces Flask’s request fields only for the trusted proxy count you configure.
  4. Your route validates the resulting address and performs a lookup.

2. Choose a hosted lookup or local database

Concern Hosted API Local database
Integration HTTP request and JSON parsing Install a reader and ship a database file
External disclosure The queried IP is sent to the vendor No per-request vendor call
Availability Depends on network, provider uptime and rate limits Works during provider outages if the database is present
Operations Provider updates the data; review API terms You must license, download and update the database
Cost May have quotas or per-request pricing License and storage costs vary

IP-API.com documentation describes a hosted option. Its unauthenticated service is limited to non-commercial use and 45 requests per minute; commercial use requires Pro according to its terms. These are provider-specific conditions, so verify current terms before deployment. MaxMind’s Python package supports local database readers, while MaxMind’s web services provide a hosted alternative.

3. A complete Flask implementation with a hosted provider

The example below keeps the lookup on the server, applies finite timeouts, validates IPv4 and IPv6, and returns only the fields the application needs. Set GEOIP_ENDPOINT to the provider endpoint allowed by your account and review its response schema and terms.

from __future__ import annotations

import ipaddress
import os
from typing import Any

import requests
from flask import Flask, jsonify, request
from werkzeug.exceptions import BadRequest

app = Flask(__name__)
GEOIP_ENDPOINT = os.environ.get('GEOIP_ENDPOINT', 'https://ip-api.com/json')
LOOKUP_TIMEOUT = (2, 4)  # connect, read seconds


def client_ip() -> str:
    """Return the address Flask selected after proxy handling."""
    value = request.remote_addr
    if not value:
        raise BadRequest('No remote address is available')
    try:
        address = ipaddress.ip_address(value)
    except ValueError as exc:
        raise BadRequest('Remote address is not a valid IP') from exc
    if address.is_unspecified:
        raise BadRequest('Unspecified addresses are not valid lookup inputs')
    return str(address)


def lookup(ip: str) -> dict[str, Any]:
    response = requests.get(
        GEOIP_ENDPOINT,
        params={'query': ip, 'fields': 'status,country,regionName,city,timezone,lat,lon'},
        timeout=LOOKUP_TIMEOUT,
    )
    response.raise_for_status()
    payload = response.json()
    if payload.get('status') == 'fail':
        raise ValueError(payload.get('message', 'Provider rejected the address'))
    return {
        'country': payload.get('country'),
        'region': payload.get('regionName'),
        'city': payload.get('city'),
        'timezone': payload.get('timezone'),
        'latitude': payload.get('lat'),
        'longitude': payload.get('lon'),
    }


@app.get('/location')
def location():
    try:
        ip = client_ip()
        # Private, loopback and reserved values normally have no public geography.
        parsed = ipaddress.ip_address(ip)
        if parsed.is_private or parsed.is_loopback or parsed.is_reserved:
            return jsonify({'ip': ip, 'location': None, 'reason': 'non-public address'})
        return jsonify({'ip': ip, 'location': lookup(ip)})
    except requests.Timeout:
        app.logger.warning('GeoIP provider timed out')
        return jsonify({'error': 'location temporarily unavailable'}), 503
    except requests.RequestException:
        app.logger.exception('GeoIP provider request failed')
        return jsonify({'error': 'location temporarily unavailable'}), 503
    except (ValueError, BadRequest) as exc:
        return jsonify({'error': str(exc)}), 400


if __name__ == '__main__':
    app.run(debug=False)

Install dependencies with python -m pip install Flask requests, set GEOIP_ENDPOINT if needed, and run the application with a production WSGI server rather than Flask’s development server. Keep credentials in environment variables or your deployment secret manager, never in browser JavaScript.

Calling the route

curl -i http://localhost:5000/location

A successful response can contain a country, broad region, city and timezone. A private or unrecognized address returns location: null. Do not assume every field is present.

4. Handling reverse proxies safely

Configure ProxyFix at the application boundary only when you control and understand the proxy chain:

import os
from werkzeug.middleware.proxy_fix import ProxyFix

# Set this to the exact number of trusted proxy hops in your deployment.
trusted_proxy_count = int(os.environ.get('TRUSTED_PROXY_COUNT', '1'))
app.wsgi_app = ProxyFix(
    app.wsgi_app,
    x_for=trusted_proxy_count,
    x_proto=trusted_proxy_count,
    x_host=trusted_proxy_count,
    x_port=trusted_proxy_count,
    x_prefix=trusted_proxy_count,
)

Your edge must overwrite, rather than blindly append to, X-Forwarded-For and related headers. Setting the count too high lets an attacker provide a forged address; setting it too low makes the proxy address appear to be the client. If traffic can arrive directly at the WSGI server, firewall it so clients cannot bypass the trusted edge. Avoid helpers that always take the first value in X-Forwarded-For without defining the trusted boundary.

5. Local MaxMind database option

A local reader removes the live lookup dependency, but you must obtain a database under its license, deploy it, and establish an update process. The GeoIP2 Python repository documents the reader API.

import geoip2.database
from flask import jsonify, request

reader = geoip2.database.Reader('/srv/geoip/GeoLite2-City.mmdb')

@app.get('/local-location')
def local_location():
    ip = client_ip()
    address = ipaddress.ip_address(ip)
    if address.is_private or address.is_loopback or address.is_reserved:
        return jsonify({'ip': ip, 'location': None})
    try:
        city = reader.city(ip)
    except geoip2.errors.AddressNotFoundError:
        return jsonify({'ip': ip, 'location': None, 'reason': 'not in database'})
    return jsonify({'ip': ip, 'location': {
        'country': city.country.iso_code,
        'region': city.subdivisions.most_specific.iso_code,
        'city': city.city.name,
        'timezone': city.location.time_zone,
        'latitude': city.location.latitude,
        'longitude': city.location.longitude,
    }})

Open the reader once at process startup and close it during orderly shutdown. Do not download or replace the database on every request. Test your update job, file permissions and rollback path.

6. Validation and edge cases

  • IPv4 and IPv6: use ipaddress.ip_address; do not split on colons or assume dotted notation.
  • Private, loopback and reserved ranges: return no public location or apply an explicit internal policy.
  • Missing values: health checks and unusual WSGI setups may have no remote address; fail closed with a useful error.
  • Provider nulls: country, city, coordinates and timezone can be absent for unknown ranges.
  • NAT, VPNs, mobile networks and corporate gateways: the result may describe an egress point rather than the person or device.
  • IPv4-mapped IPv6: normalize only if your provider’s documentation requires it, and preserve the original for audit purposes only when justified.
  • Forwarded header spoofing: never let an arbitrary browser choose the lookup input.

7. Privacy, accuracy and terms

MaxMind cautions that geolocation should not identify a particular address or household. IP-derived geography is an estimate and is not consented device GPS or verified identity. Do not use it alone for account access, fraud decisions, age checks or legal residence.

The EDPB FAQ lists IP addresses and location data as examples of personal data. Apply purpose limitation, data minimisation, accuracy, storage limitation, integrity and confidentiality. For EU/EEA deployments, assess whether GDPR applies, identify the lawful basis and provide the transparency required for your actual purpose; consult local counsel for a concrete determination. Prefer broad country or region fields when they satisfy the feature, avoid retaining raw IPs indefinitely, restrict logs, and define deletion and access controls.

Review the provider’s commercial rights, retention policy, rate limits, data sources and disclosure terms before sending addresses. The ip-api.io tutorial publishes vendor claims of 99.8% country accuracy, 85–95% city accuracy and an approximately 50 km median coordinate radius; these are not independent benchmarks and should not be generalized.

8. Performance and reliability

  • Use short connect and read timeouts and return a controlled fallback when the provider is unavailable.
  • Cache results only when the provider license and your privacy policy allow it. Set a finite TTL and avoid caching raw addresses longer than necessary.
  • Do not block every page render on a geolocation call. Resolve asynchronously or use a broad default when the feature is non-critical.
  • For high volume, a local database avoids per-request network latency and rate limits, at the cost of update operations.
  • Measure provider latency, timeout rate, null-result rate and quota usage. Do not infer global accuracy from a small internal sample.
  • Keep response fields narrow and avoid logging full provider payloads.

9. Troubleshooting

Symptom Likely cause Fix
Every user appears to be the load balancer Proxy headers are not trusted by Flask Configure ProxyFix for the exact proxy count and verify the edge overwrites headers.
Users can choose their own location Arbitrary forwarding headers are accepted Block direct access to the app and trust headers only from known proxies.
IPv6 requests fail Code assumes IPv4 strings Use ipaddress.ip_address and confirm provider IPv6 support.
Private IP has no city It is not publicly geolocatable Return a deliberate null result or an internal region policy.
Requests hang No finite network timeout Set connect and read timeouts and handle timeout exceptions.
HTTP 429 or quota errors Provider rate limit reached Throttle, cache within allowed terms, queue non-critical work, or choose a suitable plan/provider.
Database lookup raises AddressNotFound Range is absent or database is stale Return an unknown result, check update jobs and verify the database path.
Coordinates look precise Consumers mistake an estimate for GPS Label output as approximate and expose only the granularity your purpose needs.

10. Or skip the browser setup

If your Flask feature also needs a clean screenshot of a location-aware page, ScreenshotNeo provides a single GET request that returns PNG, JPEG, WebP or PDF. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups and chat widgets. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed; response headers identify the page verdict and billing result.

See the ScreenshotNeo API documentation for all options.

curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://example.com'}, timeout=90)
r.raise_for_status()
open('shot.webp', 'wb').write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info and capture_pdf for Claude, Cursor and other MCP clients. It has 1,000 screenshots per month free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

11. FAQ

Can Flask detect a user’s exact address?

No. IP geolocation estimates the network’s broad location and can be wrong because of VPNs, proxies, mobile carriers and shared gateways.

Should the browser send its IP to Flask?

No. The server receives the network connection metadata. A browser-provided value is user-controlled and should not replace trusted proxy handling.

Is a local database always more private?

It avoids sending each query to a vendor, but your own logs, retention and access controls still determine privacy. Licensing and update duties also apply.

Can I use geolocation for authorization?

Do not rely on it alone. Combine it with an explicit security control and treat location as a weak, approximate signal.