Masking Precise Coordinates for Unprivileged Roles
Reduce precision in the query, not in the renderer. Snap to a grid or aggregate to administrative areas inside PostGIS, choose the grid from the data’s local density, suppress single-record cells — and do not reach for random jitter, which repeated queries defeat.
Jump to heading Why this matters
Plenty of spatial datasets are useful in aggregate and sensitive at full precision: incident reports, health events, protected-species sightings, customer addresses. The usual arrangement is that a privileged role sees exact locations and everybody else sees something coarser, and the coarsening is where the design work is.
The temptation is to treat this as a rendering problem — load the precise geometry, blur it before drawing — and it is not. It is an access problem, and it belongs at the same boundary as the row-level authorisation described in security boundaries & auth. If exact coordinates reach the dashboard process, they are recoverable from that process, and the fact that they were not drawn on the map is not the property anybody is going to ask about.
Jump to heading Prerequisites
- PostGIS 3.x, or another spatial database that can snap and aggregate server-side.
- The role extraction from role-based access control for internal dashboards, so the query knows which precision to serve.
- A projected CRS for the snapping. Snapping in degrees produces cells that are rectangles of wildly varying ground size by latitude, which is the same unit trap as everywhere else.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Put the masking in the SQL
PRECISE_ROLES = {"analyst_full", "data_steward"}
def fetch_incidents(conn, bounds_wkb: bytes, role: str, grid_m: int = 1000):
if role in PRECISE_ROLES:
geom_expr = "geom"
group_by = ""
else:
# Snap in a projected CRS, then hand back geographic coordinates.
geom_expr = (f"ST_Transform(ST_SnapToGrid("
f"ST_Transform(geom, 27700), {grid_m}), 4326)")
group_by = f"GROUP BY {geom_expr} HAVING count(*) >= 5"
query = f"""
SELECT {geom_expr} AS geom, count(*) AS n
FROM incidents
WHERE ST_Intersects(geom, ST_GeomFromWKB(%s, 4326))
{group_by or 'GROUP BY geom'}
"""
return gpd.read_postgis(query, conn, params=(bounds_wkb,), geom_col="geom",
crs="EPSG:4326")
The HAVING count(*) >= 5 is doing as much work as the snap. Snapping bounds how precisely a record is placed; it does nothing about a cell that ends up containing exactly one record, which is a location whatever the grid size says. Suppressing sparse cells is what turns a coarse point layer into something that cannot be inverted at its edges.
Jump to heading Step 2 — Choose the grid from the data, not from a round number
-- Run this once per candidate resolution, per region, before choosing.
SELECT count(*) FILTER (WHERE n = 1)::float / count(*) AS single_record_share
FROM (
SELECT ST_SnapToGrid(ST_Transform(geom, 27700), 500) AS cell, count(*) AS n
FROM incidents GROUP BY cell
) cells;
Run it separately for a dense region and a sparse one. A single global grid that satisfies both is usually much coarser than the urban data needs; where that loss matters, a variable resolution — finer where density supports it — is defensible, provided the resolution itself is not published in a way that reveals density.
Jump to heading Step 3 — Make the coarse layer honest about being coarse
A map of grid centroids drawn as small dots looks exactly like a map of precise locations, and users will read it as one. Draw the cells as cells: squares at the grid resolution, or a choropleth over the administrative areas, so the visual carries the precision the data actually has.
# Render cells as cells, not as points that happen to be on a grid.
cells = subset.copy()
cells["geometry"] = cells.geometry.buffer(grid_m / 2, cap_style=3) # squares
This is a security control as much as a design one. A dot implies a location; a square implies an area, and an analyst who copies the layer into their own work carries the correct implication with it.
Jump to heading Step 4 — Log the role alongside the query
log.info("spatial_query", extra={
"role": role, "masked": role not in PRECISE_ROLES,
"grid_m": None if role in PRECISE_ROLES else grid_m,
"rows": len(result),
})
An audit asks two questions: who saw precise data, and can you prove it. A structured log record per query answers both, and it fits the schema from logging spatial query performance with one extra field.
Jump to heading Verification
# 1. An unprivileged role never receives a coordinate off the grid.
masked = fetch_incidents(conn, bounds, role="viewer", grid_m=1000)
xs = masked.to_crs(27700).geometry.x
assert (xs % 1000 == 0).all(), "an unsnapped coordinate escaped"
# 2. No cell is below the suppression floor.
assert (masked["n"] >= 5).all(), "a sparse cell was returned"
# 3. The privileged path is genuinely different.
precise = fetch_incidents(conn, bounds, role="data_steward")
assert len(precise) > len(masked), "the roles returned the same thing"
The third assertion is the one worth having in CI. A refactor that accidentally routes both roles through the same branch produces a dashboard that works perfectly, looks correct to everybody, and quietly serves precise coordinates to viewers — and nothing else in the system will notice.
Jump to heading Edge cases and gotchas
- Snapping in degrees.
ST_SnapToGridon geographic coordinates makes cells whose ground width shrinks with latitude, so a “one kilometre” grid is one kilometre in exactly one place. Transform to a projected CRS, snap, transform back. - Suppression leaks at the boundary. If a cell disappears when it drops below five records, an observer who can query repeatedly over time learns when a cell crossed the threshold. Where that matters, suppress on a fixed published schedule rather than live.
- Joining a masked layer to a precise one. An analyst who has a masked incidents layer and a precise addresses layer can sometimes intersect them back to a single candidate. Masking one layer does not protect it if the other half of the join is available.
- Caching across roles. A cache key that omits the role will serve a privileged result to an unprivileged session. Put the role — or better, the precision — in the key, as query result caching describes.
Jump to heading FAQ
Is aggregating to administrative areas better than a grid?
Usually, for two reasons. The boundaries are already public, so the aggregation reveals nothing about the underlying density the way a chosen grid resolution can. And the areas match how the results will be discussed and acted on, which makes the map more useful rather than merely safer. The cost is that areas vary enormously in size, so the effective precision is uneven.
Can I let privileged users toggle precision in the interface?
Yes, provided the toggle is a request for a different query rather than a client-side switch over data already sent. If both precisions are in the browser, the toggle is decoration and the precise data is one devtools panel away.
What about exports?
Exports are the most common leak, because they are often wired to a different code path from the map. Route them through the same masked query, and include the role and grid size in the exported file’s metadata so a spreadsheet that escapes into someone’s email still says what it is.
Back to Security Boundaries & Auth.