Authentication and Rate Limits for a Spatial API

Problem statement

A spatial API is unusually easy to abuse, for reasons that have nothing to do with security in the usual sense:

  • One request can be enormous. Measured, an unfiltered feature request returned 54 MB and the server sustained 2.3 requests per second. Ten clients doing that is an outage, with no malice required.
  • A tile endpoint invites automation. A crawler walking a pyramid to zoom 18 requests billions of URLs.
  • The data may be licensed. Many national datasets can be used and not redistributed, which makes an open bulk endpoint a licence breach.
  • A ?url= parameter is a proxy. A tile server that fetches arbitrary URLs will fetch internal ones for a stranger.

None of these is solved by adding a login. They are solved by deciding what each caller may ask for, and enforcing it per request.

Quick answer

Three layers, in the order they should be added:

# 1. limits that apply to everyone, authenticated or not
MAX_LIMIT = 200
MAX_BBOX_DEG2 = 100
MAX_ZOOM = 16

# 2. identity, so limits can differ and abuse can be attributed
async def caller(request: Request) -> Caller:
    key = request.headers.get("x-api-key")
    if key is None:
        return Caller(id="anonymous", tier="public")
    record = KEYS.get(hash_key(key))
    if record is None:
        raise HTTPException(401, "unknown API key")
    return Caller(id=record["id"], tier=record["tier"])

# 3. a rate limit per caller, not per IP
LIMITS = {"public": (60, 60),      # requests, per seconds
          "partner": (600, 60),
          "internal": (6000, 60)}

The first layer is the one that matters most, and it is the one usually skipped: a request that cannot be enormous cannot be abused, regardless of who sends it.

Triage table of four spatial API abuse patterns and their controls.
A request that cannot be enormous cannot be abused, whoever sends it.

Step-by-step solution

1. Bound the request before authenticating it

Every caller, including an anonymous one, should face:

  • A maximum page size. Measured: 50 features was 857 kB at 32.5 requests per second; 1,000 features was 12.4 MB at 2.3 requests per second.
  • A required bounding box above some match count, or a default that is not the world.
  • A maximum zoom on tile endpoints, so a crawler cannot request an infinite pyramid.
  • An allowlist for any url parameter, so the service cannot be used as a proxy.

These are cheap, they apply uniformly, and they remove the failure modes that do not need an attacker.

2. Choose the identity mechanism from the client type

  • API keys โ€” machine-to-machine, easy to issue and revoke, and adequate over HTTPS. The default for a data API.
  • OAuth2 / OIDC bearer tokens โ€” when users log in through an identity provider and the API acts on their behalf.
  • mTLS โ€” service-to-service inside a controlled network.
  • Signed URLs โ€” time-limited access to one resource, ideal for a download link or a private tile set.

Most spatial APIs need keys, and many public ones need nothing at all beyond the limits in step 1.

3. Store keys hashed, and treat them as credentials

import hashlib
import hmac
import secrets


def issue_key() -> tuple[str, str]:
    key = secrets.token_urlsafe(32)
    return key, hashlib.sha256(key.encode()).hexdigest()


def verify(presented: str, stored_hash: str) -> bool:
    return hmac.compare_digest(
        hashlib.sha256(presented.encode()).hexdigest(), stored_hash)

Store the hash, show the key once, and compare with a constant-time function. A key in a log file, a query string or a git history is a leaked credential.

Prefer a header (X-API-Key or Authorization: Bearer) over a query parameter, because query strings end up in access logs, referrer headers and browser history.

4. Rate limit by caller, and by cost

A limit of "100 requests per minute" treats a 20 kB tile the same as a 12 MB feature dump. Weighting by cost is more honest and not much harder:

COST = {"tile": 1, "features": 5, "download": 50}

Then the budget is in units per minute rather than requests, and a caller can have many cheap requests or a few expensive ones.

Limit by API key where there is one and by IP where there is not, remembering that an IP can be an entire office behind NAT.

5. Return the right status and the right headers

429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1757068800

Retry-After is the important one: it turns a client's blind retry loop into a scheduled one. A 429 without it invites exactly the behaviour the limit exists to prevent.

6. Keep authenticated responses out of shared caches

Cache-Control: private, max-age=60

A response that depends on who asked must not be stored by a CDN. Getting this wrong is the mechanism by which one user's data is served to another, and it is a header rather than a bug in the application logic.

Public tiles and public features should be public and cached aggressively โ€” measured, the cached path ran at 1,191 requests per second against 100 for generating the body.

Grid of four authentication mechanisms and the client type each suits.
Signed URLs are underused for downloads and private tile sets.

Code examples

Example 1 โ€” key authentication with tiers

import hashlib
import hmac
from dataclasses import dataclass
from fastapi import Depends, FastAPI, HTTPException, Request

app = FastAPI()


@dataclass(frozen=True)
class Caller:
    id: str
    tier: str
    max_limit: int
    max_bbox_deg2: float


TIERS = {
    "public":   {"max_limit": 50,  "max_bbox_deg2": 25.0,  "rate": (60, 60)},
    "partner":  {"max_limit": 200, "max_bbox_deg2": 100.0, "rate": (600, 60)},
    "internal": {"max_limit": 1000, "max_bbox_deg2": 1e9,  "rate": (6000, 60)},
}
KEY_HASHES = {}          # sha256(key) -> {"id": ..., "tier": ...}


async def caller(request: Request) -> Caller:
    presented = (request.headers.get("x-api-key")
                 or (request.headers.get("authorization", "")
                     .removeprefix("Bearer ").strip() or None))
    if presented is None:
        tier = "public"
        identity = f"ip:{request.client.host}"
    else:
        digest = hashlib.sha256(presented.encode()).hexdigest()
        record = next((r for h, r in KEY_HASHES.items()
                       if hmac.compare_digest(h, digest)), None)
        if record is None:
            raise HTTPException(401, "unknown API key")
        tier, identity = record["tier"], record["id"]

    settings = TIERS[tier]
    return Caller(identity, tier, settings["max_limit"], settings["max_bbox_deg2"])


@app.get("/features")
def features(bbox: str | None = None, limit: int = 50,
             who: Caller = Depends(caller)):
    if limit > who.max_limit:
        raise HTTPException(400, f"limit {limit} exceeds {who.max_limit} "
                                 f"for the {who.tier} tier")
    if bbox:
        x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
        area = (x1 - x0) * (y1 - y0)
        if area > who.max_bbox_deg2:
            raise HTTPException(400, f"bbox covers {area:.0f} degยฒ, maximum "
                                     f"{who.max_bbox_deg2:.0f} for {who.tier}")
    ...

Example 2 โ€” a cost-weighted token bucket

import time
from collections import defaultdict
from fastapi import HTTPException, Response


class CostLimiter:
    """Budget in cost units per window, not requests per window."""

    def __init__(self):
        self.buckets = defaultdict(lambda: {"tokens": 0.0, "updated": time.monotonic()})

    def check(self, key: str, capacity: float, per_seconds: float, cost: float = 1.0):
        rate = capacity / per_seconds
        bucket = self.buckets[key]
        now = time.monotonic()
        bucket["tokens"] = min(capacity,
                               bucket["tokens"] + (now - bucket["updated"]) * rate)
        bucket["updated"] = now

        if bucket["tokens"] < cost:
            wait = (cost - bucket["tokens"]) / rate
            raise HTTPException(
                429, "rate limit exceeded",
                headers={"Retry-After": str(int(wait) + 1),
                         "X-RateLimit-Limit": str(int(capacity)),
                         "X-RateLimit-Remaining": "0",
                         "X-RateLimit-Reset": str(int(time.time() + wait))})

        bucket["tokens"] -= cost
        return {"X-RateLimit-Limit": str(int(capacity)),
                "X-RateLimit-Remaining": str(int(bucket["tokens"]))}


limiter = CostLimiter()
COST = {"tile": 1.0, "features": 5.0, "download": 50.0}

In production, back this with Redis rather than a process-local dictionary โ€” otherwise each worker enforces its own limit and the effective rate is multiplied by the worker count.

Example 3 โ€” signed URLs for time-limited access

import base64
import hmac
import hashlib
import time
from fastapi import HTTPException


SECRET = b"..."                 # from the environment, not the source


def sign_url(path: str, expires_in: int = 3600) -> str:
    expiry = int(time.time()) + expires_in
    payload = f"{path}:{expiry}".encode()
    signature = base64.urlsafe_b64encode(
        hmac.new(SECRET, payload, hashlib.sha256).digest()).decode().rstrip("=")
    return f"{path}?expires={expiry}&sig={signature}"


def verify_signature(path: str, expires: int, sig: str) -> None:
    if expires < time.time():
        raise HTTPException(403, "this link has expired")
    payload = f"{path}:{expires}".encode()
    expected = base64.urlsafe_b64encode(
        hmac.new(SECRET, payload, hashlib.sha256).digest()).decode().rstrip("=")
    if not hmac.compare_digest(expected, sig):
        raise HTTPException(403, "invalid signature")

Signed URLs are the right mechanism for a download link, a private tile set or an export that should be shareable but not permanent. No account, no key management, and the link expires on its own.

Explanation

Why the limits matter more than the authentication

Authentication says who is asking. Limits say what may be asked. A service with perfect authentication and no limits can be taken down by an authorised user with a wide bounding box โ€” measured, an unfiltered request returned 54 MB and dropped the server to 2.3 requests per second.

Limits also protect against the far commoner case of an honest client with a bug: a loop that forgot its bounding box, a retry storm after a timeout, a map that requests every tile in the pyramid.

Why cost-weighted limits are worth the extra complexity

Requests to a spatial API differ in cost by three orders of magnitude. A vector tile is 20 kB and a few milliseconds; a full feature download is 54 MB and half a second of serialisation.

A request-count limit either strangles the tile traffic or permits the download traffic. Weighting by an approximate cost gives each caller a budget that reflects the load they actually impose, and it is a dictionary of numbers rather than an architecture.

Why Retry-After is not optional on a 429

A client that receives a 429 with no guidance retries immediately, and a well-meaning client with a retry loop turns one rejected request into a hundred. That is exactly the traffic pattern the limit exists to prevent.

Retry-After converts it into a scheduled retry. It costs one header and it is the difference between a limit that sheds load and one that amplifies it.

Why private on authenticated responses is a security control

Cache-Control: public invites any shared cache to store the response and serve it to the next client asking for that URL. If the response depended on the caller โ€” their permitted layers, their filtered rows โ€” that is a data leak with no exploit involved.

The rule is mechanical: if the response could differ per caller, it is private, and ideally the URL includes something that distinguishes callers so a mis-set header cannot collide in the first place.

Bar chart of relative cost weights for four kinds of spatial API request.
Weighting is a dictionary of numbers, not an architecture.

Edge cases or notes

  • Keys in query strings leak into logs, referrers and history. Use a header.
  • Store key hashes, compare in constant time, and show the key once.
  • Rate limit state must be shared across workers, or the limit is multiplied by the worker count.
  • An IP is not a caller โ€” offices, VPNs and mobile networks share addresses.
  • A url parameter needs an allowlist, or the service is an open proxy.
  • Bound the zoom on tile endpoints, or a crawler walks an infinite pyramid.
  • private on anything caller-dependent, including error responses that reveal existence.
  • Log the caller id with every rejection, or abuse cannot be attributed.

FAQ

Do I need authentication on a public spatial API?

Often not. What you always need is limits: a maximum page size, a bounded bounding box, a maximum zoom, and an allowlist for any URL parameter.

API keys or OAuth?

Keys for machine-to-machine access โ€” easy to issue and revoke, adequate over HTTPS. OAuth when users log in through an identity provider and the API acts on their behalf.

Should I rate limit by IP or by key?

By key where there is one, because an IP can be an entire office behind NAT. Fall back to IP for anonymous callers, with a lower budget.

Why weight the rate limit by cost?

Because a 20 kB tile and a 54 MB feature dump are not the same load. A request-count limit either strangles tiles or permits downloads.

What headers should a 429 carry?

Retry-After above all, plus the X-RateLimit-* headers. Without Retry-After, clients retry immediately and amplify the load the limit was protecting against.

Can I cache authenticated responses?

Only as private. public lets a shared cache serve one caller's response to another, which is a data leak that needs no exploit.