Detecting AIS Spoofing & GPS Jamming

AIS was designed for collision avoidance, not authentication, so nothing in the protocol prevents a transmitter from broadcasting a false position, a borrowed identity, or a fabricated course. That openness makes falsified and jammed positions a routine data-quality problem rather than an exotic one: analysts see sudden multi-kilometre teleport jumps, identity swaps where one MMSI suddenly reports a different ship, impossible speeds, circling or frozen positions from GPS jamming near contested waters, and coordinated location spoofing that parks a vessel somewhere it never sailed. This page is a focused detector for those signatures and is one stage of the broader Anomaly Detection in AIS Trajectories workflow, the parent page to return to. It leans on two orthogonal principles — kinematic implausibility (no real vessel moves like that) and identity inconsistency (a vessel’s static attributes should not change mid-voyage) — and returns flagged anomalies with explicit reason codes.

Root Cause: Why Unauthenticated AIS Is Detectable Anyway

Because AIS carries no cryptographic signature, a receiver cannot verify who sent a message. But a falsified track still has to obey — or visibly violate — physics and its own declared identity, and that is the lever every detector here pulls.

Kinematic implausibility is the first test. A position that jumps 40 km in 20 seconds implies roughly 3,900 knots; a real vessel cannot, so either the position or the timestamp is fabricated. Spoofers that “jump” a vessel to a decoy location almost always break the speed budget at the seam between real and fake fixes.

Identity inconsistency is the second, independent test. The MMSI, ship name, call sign, and reported dimensions are static attributes that should hold constant for a voyage. When one MMSI suddenly reports a new name or a hull that changed length by 100 metres, the identity was swapped even if every position is individually plausible. Reproduce a teleport seam:

import numpy as np
import pandas as pd
from pyproj import Geod

_GEOD = Geod(ellps="WGS84")

# A clean transit with one spoofed fix teleported 40 km offshore.
track = pd.DataFrame({
    "mmsi": [412000111] * 4,
    "timestamp": pd.to_datetime([
        "2026-04-02T14:00:00Z", "2026-04-02T14:00:20Z",
        "2026-04-02T14:00:40Z", "2026-04-02T14:01:00Z",
    ], utc=True),
    "lat": [22.30, 22.31, 22.67, 22.32],   # 3rd fix is spoofed
    "lon": [114.10, 114.11, 114.55, 114.12],
})

lat = track["lat"].to_numpy(); lon = track["lon"].to_numpy()
_, _, dist = _GEOD.inv(lon[:-1], lat[:-1], lon[1:], lat[1:])
dt = track["timestamp"].diff().dt.total_seconds().to_numpy()[1:]
print("implied knots:", np.round((dist / dt) * 3600 / 1852, 0))
# implied knots: [ 47. 3900. 3900.] -> the seam trips the kinematic gate twice

The spoofed fix trips the speed gate symmetrically on both adjacent legs — the outbound teleport and the return — which is the fingerprint of a single injected position rather than a real course change.

AIS anomaly signatures and their reason codes Three panels. Left: a mostly straight vessel track with one fix jumping far off the line and back, labelled teleport jump, reason code speed_outlier. Centre: many identical overlapping fixes at one point, labelled stuck position, reason code gps_stuck, characteristic of GPS jamming. Right: a small ring of fixes with no net progress, labelled circular drift, reason code gps_circle. Each panel points down to a shared detector box that emits reason codes. teleport jump reason: speed_outlier stuck position N identical fixes reason: gps_stuck circular drift reason: gps_circle detect_anomalies() -> flagged fixes + reason codes kinematic gate · position-jump test · identity check · jamming detectors

Step-by-Step Fix

Step 1 — Kinematic gate on speed and acceleration

The first, cheapest test rejects positions that violate the vessel’s motion budget. It reuses the same geodesic-step logic as the AIS data quality and gap filling stage, but here a failure is recorded as an anomaly with a reason code rather than silently dropped, because the pattern of failures is itself evidence.

from __future__ import annotations

import logging
from dataclasses import dataclass

import numpy as np
import pandas as pd
from pyproj import Geod

logger = logging.getLogger(__name__)
_GEOD = Geod(ellps="WGS84")


@dataclass(frozen=True)
class SpoofConfig:
    """Thresholds for spoofing and GPS-jamming detection."""

    max_sog_kn: float = 60.0
    max_accel_kn_per_s: float = 1.0
    jump_m: float = 5_000.0          # multi-km jump considered a teleport
    stuck_radius_m: float = 15.0     # identical-position tolerance
    stuck_min_run: int = 20          # consecutive identical fixes -> jamming
    circle_radius_m: float = 500.0   # small ring characteristic of jamming
    circle_min_points: int = 8       # points closing a ring with ~zero net travel


def _steps_m(lat: np.ndarray, lon: np.ndarray) -> np.ndarray:
    """Great-circle step distance in metres; element 0 is NaN."""
    if lat.size < 2:
        return np.full(lat.size, np.nan)
    _, _, dist = _GEOD.inv(lon[:-1], lat[:-1], lon[1:], lat[1:])
    return np.concatenate([[np.nan], dist])


def kinematic_flags(track: pd.DataFrame, cfg: SpoofConfig) -> np.ndarray:
    """Boolean array flagging fixes that break the speed/acceleration budget."""
    lat = track["lat"].to_numpy()
    lon = track["lon"].to_numpy()
    dt = track["timestamp"].diff().dt.total_seconds().to_numpy()
    step = _steps_m(lat, lon)
    with np.errstate(invalid="ignore", divide="ignore"):
        sog_kn = (step / dt) * 3600.0 / 1852.0
        accel = np.abs(np.diff(sog_kn, prepend=np.nan)) / dt
    flags = (np.nan_to_num(sog_kn, nan=0.0) > cfg.max_sog_kn) | (
        np.nan_to_num(accel, nan=0.0) > cfg.max_accel_kn_per_s
    )
    return flags

Step 2 — Position-jump versus report-interval test

A teleport is a large jump that the elapsed report interval cannot explain. Separating this from the pure speed gate matters because a jump paired with a fabricated long interval can slip under a speed threshold; testing the raw jump distance catches the decoy location regardless of the accompanying timestamp.

def jump_flags(track: pd.DataFrame, cfg: SpoofConfig) -> np.ndarray:
    """Flag multi-kilometre position jumps between consecutive fixes."""
    step = _steps_m(track["lat"].to_numpy(), track["lon"].to_numpy())
    return np.nan_to_num(step, nan=0.0) > cfg.jump_m

Step 3 — Identity consistency across the track

Static attributes must not change within a voyage. This test flags MMSI-scoped changes in ship name or reported dimensions and checks the MMSI against its encoded maritime identification digits (MID), the first three digits that name the flag state.

def identity_flags(track: pd.DataFrame) -> tuple[np.ndarray, str | None]:
    """Flag identity inconsistency within a single-MMSI track.

    Returns a boolean array plus an optional reason string. Any variation in
    ship name or reported dimensions across the track marks every fix, since
    the whole identity is suspect once a static attribute changes.
    """
    reason: str | None = None
    n = len(track)
    flags = np.zeros(n, dtype=bool)

    for col, code in (("shipname", "name_swap"), ("dim_bow", "dimension_swap")):
        if col in track.columns and track[col].nunique(dropna=True) > 1:
            flags[:] = True
            reason = code
            logger.warning("MMSI %d: %s across track", int(track["mmsi"].iloc[0]), code)

    mmsi = int(track["mmsi"].iloc[0])
    mid = mmsi // 1_000_000
    if not (200 <= mid <= 799):
        flags[:] = True
        reason = reason or "invalid_mid"
    return flags, reason

Step 4 — GPS-jamming stuck-position and circular-drift detectors

GPS jamming produces two hallmark signatures. A jammed receiver may hold the last valid fix, emitting a run of identical positions (“stuck”), or it may report small, tight circular drift with no net progress. Both are detected geometrically.

def jamming_flags(track: pd.DataFrame, cfg: SpoofConfig) -> tuple[np.ndarray, str | None]:
    """Flag GPS-jamming stuck-position runs and small circular-drift rings."""
    lat = track["lat"].to_numpy()
    lon = track["lon"].to_numpy()
    n = len(lat)
    flags = np.zeros(n, dtype=bool)
    reason: str | None = None

    step = np.nan_to_num(_steps_m(lat, lon), nan=np.inf)
    stuck = step < cfg.stuck_radius_m
    run = 0
    for i in range(n):
        run = run + 1 if (i > 0 and stuck[i]) else 0
        if run >= cfg.stuck_min_run:
            flags[i - run + 1: i + 1] = True
            reason = "gps_stuck"

    # Circular drift: a window with near-zero net displacement but non-trivial path.
    w = cfg.circle_min_points
    for i in range(n - w):
        seg_lat, seg_lon = lat[i:i + w], lon[i:i + w]
        _, _, net = _GEOD.inv(seg_lon[0], seg_lat[0], seg_lon[-1], seg_lat[-1])
        path = np.nansum(_steps_m(seg_lat, seg_lon))
        if net < cfg.circle_radius_m and path > 3.0 * cfg.circle_radius_m:
            flags[i:i + w] = True
            reason = reason or "gps_circle"
    if reason:
        logger.warning("MMSI %d: jamming signature %s", int(track["mmsi"].iloc[0]), reason)
    return flags, reason


def detect_anomalies(track: pd.DataFrame, cfg: SpoofConfig | None = None) -> pd.DataFrame:
    """Return the track with per-fix ``anomaly`` flag and ``reason`` columns.

    Raises:
        ValueError: if the track spans more than one MMSI.
    """
    cfg = cfg or SpoofConfig()
    if track["mmsi"].nunique() != 1:
        raise ValueError("detect_anomalies expects a single-MMSI track")

    t = track.sort_values("timestamp").reset_index(drop=True)
    kin = kinematic_flags(t, cfg)
    jmp = jump_flags(t, cfg)
    idflags, id_reason = identity_flags(t)
    jam, jam_reason = jamming_flags(t, cfg)

    t["anomaly"] = kin | jmp | idflags | jam
    t["reason"] = np.select(
        [idflags, kin, jmp, jam],
        [id_reason or "identity", "speed_outlier", "position_jump",
         jam_reason or "gps_jamming"],
        default="ok",
    )
    logger.info("MMSI %d: %d/%d fixes flagged",
                int(t["mmsi"].iloc[0]), int(t["anomaly"].sum()), len(t))
    return t

Verification and Acceptance Test

Detectors are only trustworthy if they fire on known spoofs and stay quiet on clean tracks. Inject a labelled synthetic teleport into a clean track, run the detector, and measure precision on a small check set.

def test_detects_injected_teleport() -> None:
    """Inject a spoofed fix and assert it is flagged; measure precision."""
    clean = pd.DataFrame({
        "mmsi": [412000222] * 6,
        "timestamp": pd.date_range("2026-05-01T00:00:00Z", periods=6, freq="20s"),
        "lat": [1.20, 1.201, 1.202, 1.203, 1.204, 1.205],
        "lon": [103.80, 103.801, 103.802, 103.803, 103.804, 103.805],
    })
    spoofed = clean.copy()
    spoofed.loc[3, ["lat", "lon"]] = [1.60, 104.30]   # injected teleport

    out = detect_anomalies(spoofed)
    assert bool(out.loc[3, "anomaly"]), "injected teleport not flagged"

    truth = np.zeros(len(out), dtype=bool); truth[3] = True
    pred = out["anomaly"].to_numpy()
    tp = int((pred & truth).sum()); fp = int((pred & ~truth).sum())
    precision = tp / (tp + fp) if (tp + fp) else 0.0
    logger.info("precision on check set: %.2f (tp=%d fp=%d)", precision, tp, fp)
    assert precision >= 0.5, f"too many false positives: precision={precision:.2f}"

The seam of an injected teleport trips the gate on both adjacent legs, so expect the fix itself plus possibly its neighbour to flag; tune jump_m and the speed thresholds against your own labelled set to trade recall for precision.

Edge Cases and Gotchas

  • Legitimate high-speed craft, SAR, and intentional AIS-off. Fast patrol boats, hydrofoils, and racing craft can genuinely exceed a conservative max_sog_kn; raise the threshold per vessel class where the type is known. Naval and SAR vessels legitimately switch AIS off, producing a benign coverage gap rather than a spoof — do not treat a clean gap as an anomaly, and coordinate with the gap-filling stage so an over-eager interpolation does not erase a deliberately dark leg.

  • Duplicate MMSI collisions look like spoofing. Two vessels sharing one MMSI produce interleaved teleports that trip every kinematic gate, yet neither vessel is falsifying anything. Disambiguate by spatially clustering the fixes: a true spoof shows one coherent track plus outliers, while a collision shows two persistent clusters. Route collisions back to AIS data quality and gap filling for MMSI splitting before judging intent.

  • GPS week rollover and stale timestamps. A receiver with a GPS week-number rollover bug stamps positions with a date years off, which corrupts every time delta and can mimic a stuck or teleporting track. Validate the timestamp epoch before kinematics, and cross-check terrestrial versus satellite receiver sources for the same fix — agreement across independent receivers is strong evidence the position is real, and divergence localizes the fault to one feed. Feed confirmed anomalies to speed and heading profiling only after clearing the reason column, so a fabricated leg never enters a kinematic profile.


Up: Anomaly Detection in AIS Trajectories