Detecting Geofence Enumeration in the Audit Trail

Spot an account sweeping your coverage area one bounding box at a time: tiling-pattern queries over the audit envelopes, coverage ratios, and thresholds that ignore ordinary map panning.

← Back to Audit Logging for Location Data Access

This page shows how to find the account that is quietly rebuilding your dataset one bounding box at a time, using nothing but the envelopes already recorded in the audit table.

Context & When to Use

Rate limiting stops the crude version of scraping. It does not stop the patient version: a client that requests one viewport-sized box every four seconds, stays comfortably inside every quota, and after a fortnight has retrieved every feature you have. Each individual request is indistinguishable from a legitimate map pan. What gives it away is the shape of the sequence — a sweep covers new ground on almost every call, while a human revisits the same few square kilometres over and over.

Because the audit trail stores each access as a geometry rather than a log line, that shape is directly queryable. Union a subject’s envelopes over a window and you have their coverage; divide coverage by request count and you have a ratio that separates the two behaviours cleanly. The trail design this depends on is described in Audit Logging for Location Data Access, and the throttle you reach for once a sweeper is found is in Rate Limiting Geofence & Tile Endpoints.

Run this as a scheduled report rather than in the request path. It is a detection control, not a gate, and it should be looking at hours of history rather than the last five seconds.

Runnable Implementation

-- Coverage report: which subjects covered the most NEW ground per request?
WITH window_access AS (
    SELECT subject_id,
           envelope,
           row_count
    FROM   access_audit
    WHERE  occurred_at >= now() - interval '24 hours'
      AND  action = 'read'
      AND  envelope IS NOT NULL
),
coverage AS (
    SELECT subject_id,
           count(*)                                       AS requests,
           sum(row_count)                                 AS rows_returned,
           -- Total area asked for, counting overlaps repeatedly
           sum(ST_Area(envelope::geography)) / 1e6        AS requested_km2,
           -- Distinct ground actually covered, overlaps collapsed
           ST_Area(ST_Union(envelope)::geography) / 1e6   AS covered_km2
    FROM   window_access
    GROUP  BY subject_id
    HAVING count(*) >= 50            -- ignore casual traffic entirely
)
SELECT subject_id,
       requests,
       rows_returned,
       round(covered_km2::numeric, 1)                            AS covered_km2,
       round((covered_km2 / requests)::numeric, 3)               AS km2_per_request,
       -- 1.0 means every request was new ground; 0.05 means heavy revisiting
       round((covered_km2 / NULLIF(requested_km2, 0))::numeric, 3) AS novelty
FROM   coverage
ORDER  BY novelty DESC, covered_km2 DESC
LIMIT  25;

novelty is the discriminating column. It is the ratio of distinct ground covered to ground requested: a client that never repeats itself scores close to 1.0, while one that pans around a neighbourhood all day scores under 0.1 because its envelopes pile up on the same ground.

What 400 requests look like from each kind of clientTwo panels showing accumulated request envelopes over 24 hours. On the left, a dispatcher made 412 requests whose envelopes pile up on one another around a depot, covering 31 square kilometres in total with a novelty score of 0.06. On the right, a sweeper made 398 requests laid out as a regular non-overlapping grid, covering 2840 square kilometres with a novelty score of 0.98. The shapes are immediately distinguishable even before any number is computed.Same request count, opposite shapesdispatcher · 412 requestscovered 31 km² · novelty0.06the same depot, all daysweeper · 398 requestscovered 2 840 km² · novelty0.98never the same ground twiceRequest volume ranks these two identically. Coverage novelty separates them by a factor of sixteen.

Key Parameters & Options

ParameterSuggestedNotes
Window24 hLong enough to see a slow sweep; short enough to run cheaply
HAVING count(*) >= 5050Removes the trailing mass of casual users before the expensive union
novelty alert threshold> 0.7Ordinary interactive use rarely exceeds 0.3
covered_km2 floor> 100 km²Prevents a handful of scattered lookups from scoring high
Grouping keysubject_idAdd ip_hash as a secondary key to catch credential sharing
SchedulehourlyDetection, not enforcement — never in the request path

From detection to response

Finding a sweeper is the easy half. The response should be graduated, because the same signature is produced by a legitimate bulk consumer who simply picked the wrong endpoint for the job.

Graduated response to a high coverage scoreFour escalating responses. Observation adds the subject to a watch list and is fully reversible with no customer impact. Throttling applies a cost-based rate limit, is reversible, and slows the sweep to an uninteresting speed. Contact asks the customer what they are building and often resolves the case, since a bulk consumer usually wants an export endpoint instead. Suspension is last, is disruptive and is the only step that should require a human decision. An arrow marks that the first two steps can be automated safely.Escalate gradually — the signature has innocent causes1 · observeadd to watch listno customer impactautomate freely2 · throttlecost-based rate limitsweep becomes too slowautomate freely3 · contact"what are you building?"often ends herehuman in the loop4 · suspendrevoke the credentialdisruptive, reversible latenever automaticInnocent causes of the same score· a new analytics integration that should be using the export endpoint instead· a tile pre-warming job walking the pyramid on a schedule· a migration backfilling a customer's own historical data through the public APIAll three are best solved by offering the right endpoint, not by blocking the wrong one.

Gotchas & Failure Modes

  • ST_Union over a full day of envelopes is expensive. On a busy subject this can be tens of thousands of polygons. Aggregate hourly into a rollup table and union the rollups, or use ST_Union on a snapped grid rather than raw envelopes.
  • Coarse envelopes flattening the signal. If the audit trail rounds envelopes to two decimal places, small adjacent requests collapse into the same box and novelty drops artificially. Two places is still fine at city scale; verify the rounding does not exceed a typical viewport.
  • Tile traffic mixed with feature traffic. A map client legitimately requests hundreds of non-overlapping tiles per pan, which scores as pure novelty. Filter to layer values that return features, or exclude action = 'read' rows originating from tile routes.
  • One subject, many API keys. A determined scraper spreads the sweep across credentials. Group by billing account as well as subject, and treat a set of accounts whose unions tile neatly together as one actor.
  • Alerting on absolute area. A customer whose licence covers a whole country legitimately reads a whole country. Compare coverage against the scope in their token rather than against a global constant — the scope model is in Encoding Geofence Boundaries in JWT Scope Claims.
  • No record of the decision. When throttling is applied, write it to the audit trail too. Otherwise next quarter nobody can explain why one account is slower than the rest.

Choosing a threshold from your own traffic

There is no universal threshold, because the ratio that separates the two populations depends on how big a typical viewport is for your clients. Derive it empirically: run the report against a week of known-good traffic, plot the distribution of km2_per_request, and set the alert above the highest legitimate value with some headroom.

On most feature APIs the distribution is strongly bimodal, so the choice is easy — there is a wide empty band between the interactive population and anything automated. If your distribution has no such gap, that usually means tile traffic is mixed into the sample, or a legitimate bulk consumer is already using the feature endpoint as an export.

Distribution of km² covered per request, one weekA histogram of subjects by coverage per request. A tall cluster of interactive users sits between 0.01 and 0.4 square kilometres per request. An empty band runs from 0.4 to 3. A short second group of automated clients sits between 3 and 9. The threshold is drawn in the empty band at 1.5, comfortably above every interactive user and well below every automated one, so it can move substantially in either direction without changing which subjects it catches.Where to put the line — one week of real trafficsubjectsinteractive usersautomated clientsthreshold 1.5 km²/reqsits in an empty band —insensitive to tuning0.010.41.539If your histogram has no empty band, tile traffic is probably in the sample — filter it out and re-plot.

Verification Snippet

-- Sanity check on known-good traffic: interactive users should score low
SELECT subject_id,
       count(*) AS requests,
       round((ST_Area(ST_Union(envelope)::geography) / 1e6
              / count(*))::numeric, 4) AS km2_per_request
FROM   access_audit
WHERE  occurred_at >= now() - interval '24 hours'
  AND  subject_id IN (SELECT subject_id FROM known_interactive_users)
GROUP  BY subject_id
ORDER  BY km2_per_request DESC
LIMIT  5;
--  subject_id | requests | km2_per_request
-- ------------+----------+-----------------
--  usr_8841   |      412 |          0.0752
# Simulate a sweep in staging and confirm the report flags it
python scripts/simulate_sweep.py --subject test_sweeper --tiles 400 --step 0.05
psql -f reports/coverage_novelty.sql | grep test_sweeper
# test_sweeper | 400 | 2840.0 | 7.100 | 0.981

← Back to Audit Logging for Location Data Access