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.
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
urlparameter, 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.
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.
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
urlparameter needs an allowlist, or the service is an open proxy. - Bound the zoom on tile endpoints, or a crawler walks an infinite pyramid.
privateon anything caller-dependent, including error responses that reveal existence.- Log the caller id with every rejection, or abuse cannot be attributed.
Internal links
- Serving spatial data explained: files, features and tiles โ the limits in the design
- How to rate limit and throttle a spatial API โ the implementation
- How to add bounding box and attribute filters to a spatial API โ bounding the request
- HTTP caching for spatial data: ETags, max-age and invalidation โ public versus private
- Fixing a spatial API that is slow under load โ what happens without limits
- How to manage pipeline secrets and credentials โ keeping keys out of the source
- Sharing a map app: public, private and in between โ the same decisions for an app
- How to test a spatial API with pytest and httpx โ testing the limits
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.