← Back to JWT Authentication for Spatial Scopes
Fit a geofence boundary inside a geo_scope claim so the token stays well under the header size limit while still describing the permitted area precisely enough to enforce.
Context & when to use
A JWT rides in the Authorization header on every request, and every reverse proxy and application server caps that header. Nginx defaults to an 8 KB large_client_header_buffers; Node’s HTTP parser and many CDNs draw the line near the same place. A base64url-encoded token triples quickly once you start embedding geometry, so the naive move — pasting a POLYGON((...)) with a few thousand vertices into the claim — produces a token that a proxy silently truncates or rejects with 431 Request Header Fields Too Large. The whole point of putting the fence in the token is lost if the token no longer fits.
You need a compact encoding when the geofence is anything more than a rectangle. Reach for this when a subject’s territory is an irregular shape (a delivery zone, a national boundary, a franchise polygon), when you scope many regions into one token, or when several services re-verify the same token per request and every byte is amplified. If the fence genuinely is an axis-aligned rectangle, a bbox array is already optimal and you can stop reading; this page is about everything more complex than a box. The encoding you pick here is what the validating spatial scope claims dependency will parse, and it is one row of the decision matrix in the parent guide on JWT authentication for spatial scopes.
Encoding size vs precision
The four practical encodings occupy different points on a size-versus-precision curve. Cheap encodings approximate the boundary with a grid; exact encodings cost bytes or a lookup.
Runnable implementation
The example covers a polygon fence with the H3 cell-set encoding — the best general-purpose choice — plus the server-side-reference fallback for polygons too large to tile cheaply. It builds the claim on the issuer and reads it on the API.
# geofence_encoding.py
# pip install "h3>=4.1" shapely pyjwt[crypto]
import json
import hashlib
import h3
from shapely.geometry import shape, Point
# --- 1. BUILD (issuer side): cover a polygon with H3 cells -------------------
def encode_h3_scope(geojson_polygon: dict, resolution: int = 7) -> dict:
"""
Fill a GeoJSON polygon with H3 cells at the given resolution.
res 7 ≈ 5.16 km² per cell; res 8 ≈ 0.74 km²; res 9 ≈ 0.11 km².
Higher resolution = tighter boundary but many more cells (≈7× per step).
"""
cells = h3.polygon_to_cells(
h3.geo_to_h3shape(geojson_polygon), resolution
)
cells = sorted(cells) # deterministic ordering
# h3.compact_cells merges full children into a coarser parent -> fewer IDs
compacted = sorted(h3.compact_cells(cells))
return {"srid": 4326, "encoding": "h3", "res": resolution, "regions": compacted}
def encode_server_ref(fence_id: str, geojson_polygon: dict) -> dict:
"""
When the H3 cell set is still too large, store the polygon server-side and
put only a stable content hash in the token. The API resolves the hash to
the polygon and confirms it has not been swapped out underneath the token.
"""
canonical = json.dumps(geojson_polygon, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canonical.encode()).hexdigest()[:16]
return {"srid": 4326, "encoding": "ref",
"regions": [{"ref": f"fence:{fence_id}", "hash": digest}]}
# --- 2. READ (API side): is a point inside the scoped cells? -----------------
def point_in_h3_scope(lon: float, lat: float, claim: dict) -> bool:
"""Membership test with no database round-trip: O(1) per scoped cell."""
res = claim["res"]
target = h3.latlng_to_cell(lat, lon, res)
scoped = set(claim["regions"])
if target in scoped:
return True
# A compacted claim may hold coarse parents; walk the target up to res floor
for parent_res in range(res - 1, 0, -1):
if h3.cell_to_parent(target, parent_res) in scoped:
return True
return False
# --- 3. Guardrail: reject a claim that would overflow the header -------------
MAX_SCOPE_BYTES = 4096 # leave headroom under the ~8 KB header limit after base64
def assert_within_budget(claim: dict) -> dict:
encoded = json.dumps(claim, separators=(",", ":"))
size = len(encoded.encode())
if size > MAX_SCOPE_BYTES:
raise ValueError(
f"geo_scope is {size} B (> {MAX_SCOPE_BYTES}); coarsen the H3 "
f"resolution or switch encoding to 'ref'."
)
return claim
if __name__ == "__main__":
poly = {"type": "Polygon", "coordinates": [[
[-2.71, 51.32], [-2.28, 51.32], [-2.28, 51.53],
[-2.71, 51.53], [-2.71, 51.32]]]}
scope = assert_within_budget(encode_h3_scope(poly, resolution=7))
print(f"cells={len(scope['regions'])} "
f"bytes={len(json.dumps(scope, separators=(',', ':')))}")
# Bath sits inside; central London does not
print(point_in_h3_scope(-2.5, 51.4, scope)) # True
print(point_in_h3_scope(-0.1, 51.5, scope)) # FalseKey parameters & options
| Parameter / knob | Controls | Guidance |
|---|---|---|
| Header limit | Hard ceiling on the whole token | Nginx large_client_header_buffers default 8 KB; keep the claim under ~4 KB |
MAX_SCOPE_BYTES | Your own budget for geo_scope | 4096 B leaves room for registered claims + base64 expansion |
H3 resolution | Cell size vs cell count | res 7 ≈ 5 km²; each +1 step ≈7× more cells and ~7× tighter boundary |
h3.compact_cells | Merges full child cells into parents | Always apply before encoding; can cut cell count 3–10× on solid regions |
| geohash prefix length | Cell size (chars) | 5 chars ≈ 4.9 km × 4.9 km; 6 chars ≈ 1.2 km × 0.6 km |
ref + hash | Token size vs a lookup | Fixed ~50 B; requires a server-side fetch and a hash re-check on read |
srid | Coordinate reference of the scope | Fix to 4326 unless the fence is authored in a projected CRS |
Choosing among these is a size-versus-precision decision; the parent decision matrix tabulates the same encodings against query mapping and use case. The encoded region describes permission, not feature data, so keep it minimal — it is an access-control artefact, not a payload.
Gotchas & failure modes
431 Request Header Fields Too Largefrom the proxy, not the app. An oversized token is rejected before FastAPI runs, so you see no application log. Reproduce withcurl -vand watch for the 431 on the proxy; the fix is a coarser H3 resolution or therefencoding, never raising the proxy buffer to hide the problem.- Forgetting
compact_cellsbloats the token silently. A solid res-9 polygon over a city can be tens of thousands of cells; compaction to mixed resolutions cuts that dramatically. Skipping it is the most common cause of a token that “randomly” exceeds the budget for larger fences. - H3 cell coverage over-includes the boundary.
polygon_to_cellsreturns every cell whose centre falls in the polygon; edge cells extend slightly beyond the true boundary, so a point just outside the fence can test inside. For hard boundaries, either raise the resolution near the edge or keep the exact polygon server-side viarefand test withST_Within. h3v3 vs v4 API drift. v4 renamed functions (geo_to_h3→latlng_to_cell,h3_to_parent→cell_to_parent). Pinh3>=4.1and expectAttributeError: module 'h3' has no attribute 'geo_to_h3'if a v3 snippet is copied in.- Reference hash not re-checked on read. If you store the polygon server-side but never compare its current hash to the claim’s
hash, an operator who edits the fence silently changes what an already-issued token permits. Re-hash the fetched polygon and reject on mismatch.
Verification
Confirm the encoded scope both fits the budget and enforces correctly:
python geofence_encoding.py
# cells=… bytes=… (bytes must be < 4096)
# True
# False# assert the encoded token itself stays under the header limit
import jwt, json
from geofence_encoding import encode_h3_scope, assert_within_budget
poly = {"type": "Polygon", "coordinates": [[
[-2.71, 51.32], [-2.28, 51.32], [-2.28, 51.53], [-2.71, 51.53], [-2.71, 51.32]]]}
scope = assert_within_budget(encode_h3_scope(poly, 7))
token = jwt.encode({"sub": "svc-test", "geo_scope": scope},
open("jwt-private.pem").read(), algorithm="RS256")
assert len(token) < 8192, "token exceeds the 8 KB header budget"
print(f"token bytes = {len(token)}")Related
- JWT Authentication for Spatial Scopes — the parent guide with the full encoding decision matrix and the signing/verification pipeline
- Validating Spatial Scope Claims in FastAPI Dependencies — parse and enforce whichever encoding you chose here inside a reusable dependency
- Bounding-Box Spatial Index Queries — the
ST_Within/ST_Intersectspredicates used when aref-encoded polygon is resolved against PostGIS
← Back to JWT Authentication for Spatial Scopes