"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.horizontalAccuracyis "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 asmargin - 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.sourceInformationexposesisSimulatedBySoftware. 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: truefrom 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, 3), GeoPoint(3, 3), GeoPoint(3, 2),
GeoPoint(1, 2), GeoPoint(1, 1), GeoPoint(3, 1), GeoPoint(3, 0)]
assert point_in_polygon(GeoPoint(2, 1.5), u_shape) is False # inside the notch
assert point_in_polygon(GeoPoint(0.5, 1.5), u_shape) is True
def test_accuracy_policy_accept_review_reject():
fence = [CircleFence("f", CP, 100)]
north_80m = GeoPoint(CP.lat + 80 / 111_195, CP.lon)
assert validate_punch(mk(CP, acc=20), fence).status is Status.ACCEPTED
assert validate_punch(mk(north_80m, acc=30), fence).status is Status.REVIEW # 80+30 > 100
assert validate_punch(mk(north_80m, acc=30), fence,
AccuracyPolicy(tolerance_m=15)).status is Status.ACCEPTED
assert validate_punch(mk(INDIA_GATE, acc=30), fence).status is Status.REJECTED
def test_polygon_fence_uses_edge_margin():
site = PolygonFence("s", (GeoPoint(28.6140, 77.2280), GeoPoint(28.6140, 77.2310),
GeoPoint(28.6118, 77.2310), GeoPoint(28.6118, 77.2280)))
assert validate_punch(mk(INDIA_GATE, acc=8), [site]).status is Status.ACCEPTED
assert validate_punch(mk(INDIA_GATE, acc=500), [site]).status is Status.REVIEW
def test_mock_location_rejected_and_unknown_device_reviewed():
fence = [CircleFence("f", CP, 100)]
assert validate_punch(mk(CP, mock=True), fence).status is Status.REJECTED
r = validate_punch(mk(CP, device="other"), fence, bound_devices={"d1"})
assert r.status is Status.REVIEW and "unrecognised device" in r.reasons
def test_impossible_travel():
mumbai = GeoPoint(19.0760, 72.8777)
prev, cur = mk(CP), mk(mumbai, minutes=30)
assert implied_speed_mps(prev, cur) > 600
r = validate_punch(cur, [CircleFence("f", CP, 100)], previous=prev)
assert any("impossible travel" in x for x in r.reasons)
# A short walk across the site is fine.
assert implied_speed_mps(mk(CP), mk(INDIA_GATE, minutes=30)) < 2
$ python -m pytest -q
........... [100%]
11 passed in 0.03s
The 111,195 m figure in the one-degree test is simply 2π × 6,371,000 / 360, the arc length of one degree on a sphere of the radius the code uses.
Privacy and consent
Location is personal data, and attendance is a context with an obvious power imbalance. Design for minimal collection:
- Collect location only at punch time. You don't need continuous background tracking to validate a check-in. If you also offer live tracking for field staff, make it a separate, clearly explained, time-boxed feature.
- Set retention. Keep the raw fix for audit, then purge or coarsen coordinates once the payroll and dispute window closes.
- Be transparent. Show employees the fence, explain why a punch went to review, and make disputes easy.
If you operate in India, the Digital Personal Data Protection Act, 2023 applies. The DPDP Rules, 2025 were notified in November 2025 with phased commencement: some provisions took effect on publication, the Consent Manager rule after one year, and most of the operational obligations (notice, security safeguards, data principal rights and others) eighteen months after publication. Timelines have been publicly discussed for revision, so confirm current status with counsel. Other jurisdictions have their own regimes; the habits above are worth adopting everywhere.
Design tips from production
- Store raw punches immutably. Write each punch to an append-only table: coordinates, accuracy, mock flag, device ID, device time and server receipt time. Store decisions and manual overrides as separate rows that reference it. Never edit the punch itself.
-
Make geofences effective-dated config. A fence has
valid_fromandvalid_to. When a branch moves or a radius changes, add a new version. Re-validating last March's punch must use last March's fence. -
Handle offline punches deliberately. Field staff lose signal. Let the app queue punches with the device timestamp, and reconcile on the server: compare device time with receipt time, run impossible-travel checks across the whole queued sequence, and flag large clock skew. Android notes that
getTime()comes from a clock that can jump, while elapsed realtime is monotonic, so send both. - Keep an audit trail. Every override needs a who, when and why.
- Tune with data. Log margins and accuracy values for a few weeks before tightening tolerances.
Build vs buy
Everything here is buildable, and a small version is the best way to learn the trade-offs. At scale you also need rosters, leave, payroll integration and device management. HRMS platforms such as TankhaPay combine geofencing with face recognition, mobile check-in and live location tracking, so it's worth weighing that against owning the pipeline yourself.
Whichever route you take, the core ideas stay the same: respect the accuracy radius, treat anti-spoofing as layered signals rather than a silver bullet, decide on the server, and keep an immutable record of what happened.
References
- Android Developers,
LocationAPI reference (getAccuracy, isMock, getTime, getElapsedRealtimeNanos): https://developer.android.com/reference/android/location/Location - Android Developers, Create and monitor geofences: https://developer.android.com/develop/sensors-and-location/location/geofencing
- Apple Developer,
CLLocation.horizontalAccuracy: https://developer.apple.com/documentation/corelocation/cllocation/horizontalaccuracy - Apple Developer,
CLLocation.sourceInformation: https://developer.apple.com/documentation/corelocation/cllocation/sourceinformation - Apple Developer,
CLLocationSourceInformation.isSimulatedBySoftware: https://developer.apple.com/documentation/corelocation/cllocationsourceinformation/issimulatedbysoftware - Android Developers, Play Integrity API overview: https://developer.android.com/google/play/integrity/overview
- Apple Developer, Establishing your app's integrity (App Attest): https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity
- Movable Type Scripts, Calculate distance, bearing and more between latitude/longitude points (Haversine): https://www.movable-type.co.uk/scripts/latlong.html
- Press Information Bureau, "Government notifies DPDP Rules to empower citizens and protect privacy" (14 Nov 2025): https://www.pib.gov.in/PressReleasePage.aspx?PRID=2190014
- Text of the DPDP Rules, 2025, G.S.R. 846(E), including Rule 1 commencement (Gazette Tracker copy): https://gazettetracker.com/g/CG-DL-E-14112025-267650
Written by the team at TankhaPay, a global HRMS and AI recruitment platform.
Top comments (0)