"Only allow check-in at the office" sounds like one if statement. In production it becomes a pile of edge cases: imprecise fixes inside concrete buildings, L-shaped warehouses, field staff who never visit the office, and the occasional fake-GPS app.

This post designs the server-side validation for a geofenced attendance system, with runnable Python (standard library only) and pytest tests, small enough to port to any stack.

Requirements

A realistic attendance system needs to handle:

  • Check-in and check-out only inside an approved geofence (office, plant, client site).
  • Multiple sites per employee. People move between branches; a punch is valid if it lands in any fence assigned to them for that date.
  • Shifts. The fence check answers "where"; the shift engine answers "when". Keep them separate so outcomes stay explainable.
  • Field staff. Sales and service teams may punch at customer locations, so you need ad-hoc or route-based fences, and a "needs review" path instead of a hard reject.

Validation should return ACCEPTED, REVIEW or REJECTED plus machine-readable reasons. Binary accept/reject is what makes these systems feel hostile.

Geofence models: circles vs polygons

Circles are a centre point plus a radius. The check is a great-circle distance compared with the radius, and the standard formula is Haversine, which treats the Earth as a sphere. Circles are trivial to configure (drop a pin, pick a radius) and they're what the mobile OS geofencing APIs use: Android's geofencing guide defines a fence as latitude, longitude and radius forming a circular area.

Polygons are a list of vertices, checked with ray casting: cast a ray from the point and count edge crossings. An odd count means the point is inside. Use polygons when the site is long, thin or irregular: a rail yard, a campus, a construction site, or an office tower beside a busy road where a generous circle would include the café across the street.

Rule of thumb: default to circles; switch to polygons when a circle covering the site would also cover a lot that isn't the site.

GPS accuracy realities

Every fix comes with an uncertainty, and the platforms are explicit about what it means:

  • On Android, Location.getAccuracy() "returns the estimated horizontal accuracy radius in meters of this location at the 68th percentile confidence level", i.e. there is a 68% chance the true position is inside a circle of that radius around the reported point.
  • On iOS, CLLocation.horizontalAccuracy is "the radius of uncertainty for the location, measured in meters": the coordinate is the centre of the circle and this value is its radius. A negative value means the coordinate is invalid.

Two consequences follow. First, a reported point inside the fence doesn't mean the person is inside. Second, even a "good" accuracy value is only a 68% statement on Android, so no threshold makes a punch certain.

Indoors it gets worse. Android's geofencing guide recommends a minimum fence radius of 100–150 m, notes that Wi-Fi-based accuracy is usually 20–50 m, and says that without Wi-Fi (for example in rural areas) accuracy can degrade to several hundred metres or even kilometres. A 30 m fence around an office floor will produce a steady stream of false rejections.

The policy used below:

  • ACCEPT if the whole uncertainty circle fits inside the fence: distance + accuracy <= radius (generalised as margin - accuracy >= -tolerance, where margin is the signed distance to the boundary).
  • REVIEW if the circle overlaps the boundary, or the fix is coarser than a ceiling (say 100 m).
  • REJECT if the punch is outside even after giving it the full accuracy radius.

The tolerance_m knob lets HR choose between "strict" and "forgiving" per site, without code changes.

Anti-spoofing signals

No single check stops a determined spoofer, and none of the techniques below is foolproof. They're signals: combine them, score them, and send ambiguous cases to review.

  • Mock-location flags. Android exposes Location.isMock(). The docs also warn that users may have legitimate reasons to mock location, so apps "should generally reject mock locations only when it is essential to their use case." Attendance arguably is one of those cases, but make the choice a policy setting. On Apple platforms, CLLocation.sourceInformation exposes isSimulatedBySoftware. Remember that a client-side flag is only as trustworthy as the client that reports it.
  • Impossible travel. Compute the speed implied by two consecutive punches. Subtract both accuracy radii from the distance first so honest jitter never trips the check. A Delhi punch followed by a Mumbai punch 30 minutes later is not a commute.
  • Device binding. Tie each employee to one or a few registered devices, and flag punches from unknown devices for review. Platform attestation, such as Google's Play Integrity API or Apple's App Attest, can help your server check that requests come from a genuine copy of your app.
  • Optional selfie or face match. Raises the cost of buddy-punching, but adds biometric data with its own consent, accuracy and fairness obligations. Make it a policy choice with a fallback path.
  • Server-side validation. The client collects; the server decides. Never trust an inside_fence: true from the app.

The code

Everything lives in one module, geofence.py, using dataclasses and the standard library.

"""Geofenced attendance: distance, point-in-polygon, punch validation, impossible travel. Pure standard library. Python 3.10+. """ from __future__ import annotations import math from dataclasses import dataclass, field from datetime import datetime from enum import Enum EARTH_RADIUS_M = 6_371_000.0 # mean Earth radius; Haversine treats Earth as a sphere  @dataclass(frozen=True) class GeoPoint: lat: float lon: float def haversine_m(a: GeoPoint, b: GeoPoint) -> float: """Great-circle distance in metres between two WGS84 lat/lon points (spherical model).""" phi1, phi2 = math.radians(a.lat), math.radians(b.lat) dphi = math.radians(b.lat - a.lat) dlmb = math.radians(b.lon - a.lon) h = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlmb / 2) ** 2 return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(min(1.0, h))) def _to_local_xy(p: GeoPoint, origin: GeoPoint) -> tuple[float, float]: """Equirectangular projection to metres around `origin`. Fine for site-sized areas.""" x = math.radians(p.lon - origin.lon) * math.cos(math.radians(origin.lat)) * EARTH_RADIUS_M y = math.radians(p.lat - origin.lat) * EARTH_RADIUS_M return x, y def point_in_polygon(p: GeoPoint, vertices: list[GeoPoint]) -> bool: """Ray casting (even-odd rule). Vertices in order, polygon implicitly closed. Treats lon as x and lat as y; valid for small polygons that don't cross the antimeridian. """ inside = False n = len(vertices) for i in range(n): a, b = vertices[i], vertices[(i + 1) % n] if (a.lat > p.lat) != (b.lat > p.lat): # edge straddles the horizontal ray  x_cross = a.lon + (p.lat - a.lat) * (b.lon - a.lon) / (b.lat - a.lat) if p.lon < x_cross: inside = not inside return inside def distance_to_polygon_edge_m(p: GeoPoint, vertices: list[GeoPoint]) -> float: """Shortest distance in metres from p to the polygon boundary (local planar approximation).""" px, py = 0.0, 0.0 pts = [_to_local_xy(v, p) for v in vertices] best = math.inf for i in range(len(pts)): (ax, ay), (bx, by) = pts[i], pts[(i + 1) % len(pts)] dx, dy = bx - ax, by - ay seg2 = dx * dx + dy * dy t = 0.0 if seg2 == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / seg2)) best = min(best, math.hypot(ax + t * dx - px, ay + t * dy - py)) return best @dataclass(frozen=True) class CircleFence: fence_id: str center: GeoPoint radius_m: float def margin_m(self, p: GeoPoint) -> float: """Signed distance to the boundary: positive = inside by that many metres.""" return self.radius_m - haversine_m(self.center, p) @dataclass(frozen=True) class PolygonFence: fence_id: str vertices: tuple[GeoPoint, ...] def margin_m(self, p: GeoPoint) -> float: d = distance_to_polygon_edge_m(p, list(self.vertices)) return d if point_in_polygon(p, list(self.vertices)) else -d Fence = CircleFence | PolygonFence class Status(str, Enum): ACCEPTED = "ACCEPTED" REVIEW = "REVIEW" REJECTED = "REJECTED" @dataclass(frozen=True) class Punch: employee_id: str device_id: str kind: str # "IN" or "OUT"  location: GeoPoint accuracy_m: float # horizontal accuracy radius reported by the OS  device_time: datetime # when the fix/punch happened on the device  is_mock: bool = False # Android Location.isMock(); iOS CLLocationSourceInformation.isSimulatedBySoftware  @dataclass(frozen=True) class AccuracyPolicy: tolerance_m: float = 0.0 # extra slack added to the fence when deciding ACCEPT  max_accuracy_m: float = 100.0 # fixes coarser than this are never auto-accepted  reject_mock: bool = True max_speed_mps: float = 70.0 # ~250 km/h; tune for your workforce (flights need a manual path)  @dataclass class Decision: status: Status fence_id: str | None = None margin_m: float | None = None reasons: list[str] = field(default_factory=list) def escalate(self, status: Status, reason: str) -> None: order = [Status.ACCEPTED, Status.REVIEW, Status.REJECTED] if order.index(status) > order.index(self.status): self.status = status self.reasons.append(reason) def implied_speed_mps(prev: Punch, cur: Punch) -> float: """Speed implied by two punches, giving the user the benefit of both accuracy radii.""" dist = max(0.0, haversine_m(prev.location, cur.location) - prev.accuracy_m - cur.accuracy_m) seconds = (cur.device_time - prev.device_time).total_seconds() if seconds <= 0: return math.inf if dist > 0 else 0.0 return dist / seconds def validate_punch( punch: Punch, fences: list[Fence], policy: AccuracyPolicy = AccuracyPolicy(), previous: Punch | None = None, bound_devices: set[str] | None = None, ) -> Decision: """Decide ACCEPTED / REVIEW / REJECTED for a single punch. Run this on the server.""" if not fences: return Decision(Status.REJECTED, reasons=["no active geofence for this employee/date"]) # Pick the fence where the punch sits deepest (largest margin).  best = max(fences, key=lambda f: f.margin_m(punch.location)) margin = best.margin_m(punch.location) d = Decision(Status.ACCEPTED, best.fence_id, round(margin, 1)) # Core rule: the whole uncertainty circle must fit inside the fence (+ tolerance).  if margin - punch.accuracy_m >= -policy.tolerance_m: d.reasons.append("accuracy circle inside fence") elif margin + punch.accuracy_m >= 0: d.escalate(Status.REVIEW, "accuracy circle overlaps fence boundary") else: d.escalate(Status.REJECTED, "outside fence even after allowing for accuracy") if punch.accuracy_m > policy.max_accuracy_m: d.escalate(Status.REVIEW, f"fix too coarse ({punch.accuracy_m:.0f} m)") if punch.is_mock: d.escalate(Status.REJECTED if policy.reject_mock else Status.REVIEW, "mock location flag set") if bound_devices is not None and punch.device_id not in bound_devices: d.escalate(Status.REVIEW, "unrecognised device") if previous is not None: v = implied_speed_mps(previous, punch) if v > policy.max_speed_mps: d.escalate(Status.REVIEW, f"impossible travel ({v:.0f} m/s since last punch)") return d 

A few design notes:

  • Polygon margin uses a local equirectangular projection; fine at site scale, not for country-sized shapes or the antimeridian.
  • Severity only ratchets up. escalate() never downgrades, so a new check can't accidentally approve something.

Sanity-checking Haversine

Connaught Place to India Gate is a handy Delhi test case. Using approximate coordinates for the centre of the CP circle (28.6315, 77.2167) and the India Gate monument (28.6129, 77.2295), the demo prints:

Haversine CP -> India Gate: 2416.3 m WGS84 geodesic (geographiclib): 2411.7 m diff=4.6 m (0.190%) 

The spherical model is within 5 m of the ellipsoidal answer over about 2.4 km, negligible next to GPS uncertainty. (Exact numbers depend on the points you pick.)

Running the validator

at office centre, 12 m fix -> ACCEPTED fence=cp-office margin=150.0 m ['accuracy circle inside fence'] near office edge, 40 m fix -> REVIEW fence=cp-office margin=16.6 m ['accuracy circle overlaps fence boundary'] on site polygon, 8 m fix -> ACCEPTED fence=site-a margin=122.3 m ['accuracy circle inside fence'] 2 km away -> REJECTED fence=cp-office margin=-1907.1 m ['outside fence even after allowing for accuracy'] mock location -> REJECTED fence=cp-office margin=150.0 m ['accuracy circle inside fence', 'mock location flag set'] coarse 400 m fix -> REVIEW fence=cp-office margin=150.0 m ['accuracy circle overlaps fence boundary', 'fix too coarse (400 m)'] new device -> REVIEW fence=cp-office margin=150.0 m ['accuracy circle inside fence', 'unrecognised device'] Delhi -> Mumbai in 30 min implies 639 m/s impossible travel punch -> REJECTED ['outside fence even after allowing for accuracy', 'impossible travel (639 m/s since last punch)'] 

Note the "near office edge" case: the point is about 17 m inside the fence, but with a 40 m accuracy radius the true position could be outside, so it goes to review.

Tests

from datetime import datetime, timedelta, timezone import pytest from geofence import (AccuracyPolicy, CircleFence, GeoPoint, PolygonFence, Punch, Status, haversine_m, implied_speed_mps, point_in_polygon, validate_punch) T0 = datetime(2026, 10, 9, 9, 0, tzinfo=timezone.utc) CP = GeoPoint(28.6315, 77.2167) INDIA_GATE = GeoPoint(28.6129, 77.2295) SQUARE = [GeoPoint(0, 0), GeoPoint(0, 1), GeoPoint(1, 1), GeoPoint(1, 0)] def mk(p, acc=10, minutes=0, mock=False, device="d1"): return Punch("E1", device, "IN", p, acc, T0 + timedelta(minutes=minutes), mock) def test_haversine_cp_to_india_gate(): # Cross-checked against a WGS84 geodesic (geographiclib) in demo.py  assert haversine_m(CP, INDIA_GATE) == pytest.approx(2416, abs=15) def test_haversine_one_degree_latitude(): assert haversine_m(GeoPoint(0, 0), GeoPoint(1, 0)) == pytest.approx(111_195, abs=1) def test_haversine_zero_and_symmetric(): assert haversine_m(CP, CP) == 0 assert haversine_m(CP, INDIA_GATE) == pytest.approx(haversine_m(INDIA_GATE, CP)) @pytest.mark.parametrize("pt,expected", [ (GeoPoint(0.5, 0.5), True), (GeoPoint(1.5, 0.5), False), (GeoPoint(0.5, -0.1), False)]) def test_point_in_polygon(pt, expected): assert point_in_polygon(pt, SQUARE) is expected def test_concave_polygon_notch_is_outside(): u_shape = [GeoPoint(0, 0), GeoPoint(0, Source:https://dev.to/tankhapay_hr/designing-a-geofenced-attendance-system-haversine-gps-accuracy-and-anti-spoofing-with-python-43jc

Related posts