IPv4 forwarding chooses a next hop, but Ethernet still needs a destination MAC address. Address Resolution Protocol (ARP) asks the local link which interface owns an IPv4 address, then caches the answer as a neighbor entry. The mapping is learned rather than permanent, so it can age out, be refreshed after a move, or be forged by another host on the same broadcast domain.
Ask on the local link
Suppose a host wants to send to 192.0.2.20 on its directly connected network. It broadcasts an ARP request containing its own IP/MAC and asks who has the target IP. The owner sends a unicast reply with its hardware address. The sender can then build an Ethernet frame; routers repeat the process for the next hop on each link instead of resolving the final remote address.
The request is deliberately small and local. It is not a DNS lookup, and it does not cross a router. An entry can be incomplete while a request is outstanding, reachable while recent traffic confirms it, stale when its timer expires, or failed when resolution did not work. Linux exposes these states with ip neigh; the older arp(8) command presents a simpler address-to-hardware view. Timer values and state transitions are kernel and interface policy, not a universal 60-second promise.
The interface is part of the answer
The same IPv4 address can appear on different links, so an ARP record is meaningful together with its device. ip neigh show dev eth0 asks about one interface; arp -an prints the interface column beside each mapping. A virtual machine move or a replaced NIC can leave an old MAC in peers’ caches. A gratuitous ARP announcement lets neighbors update sooner, but it is still an unauthenticated claim and should be monitored on sensitive networks.
A static neighbor entry (ip neigh add ... nud permanent) can pin a known gateway or appliance, but it also makes legitimate hardware changes fail until the entry is edited. Prefer dynamic learning for ordinary hosts and reserve permanent entries for a documented boundary. IPv6 does not use ARP; Neighbor Discovery performs the analogous job with ICMPv6 and different security controls.
When it breaks
An incomplete entry followed by timeouts can mean the target is down, the VLAN is wrong, or link-layer filtering blocks ARP. STALE alone means reachability needs confirmation, not that the MAC is wrong. Duplicate IPs produce competing replies and intermittent traffic. Spoofing can redirect frames because ARP does not authenticate ownership; correctly authenticated TLS still protects application contents against the impersonator.
Read the table before changing it. Compare the route’s selected device, the neighbor’s MAC and state, and a packet capture of requests and replies. On a shared or hostile segment, use switch inspection, DHCP snooping, port controls, or cryptographic link protection. Do not “fix” an uncertain incident by flushing every neighbor entry on a production host: that creates a burst of broadcasts and can remove a known-good static boundary. Record the observation time and interface, because a neighbor table is a moving cache rather than a permanent inventory. If the same address appears on two interfaces, preserve the device dimension instead of deduplicating by IP; the two rows may describe different links rather than a contradiction. Capture both the requester’s and responder’s MAC when investigating a conflict, because a visible mapping proves only what the cache learned, not who is authorized to use the address.
Check it quickly
On macOS, the first command reads the neighbor table. Save the Python block as arp_fixture_check.py before running it. Its synthetic macOS and Linux-shaped rows test interface identity and neighbor states without changing the kernel table.
/usr/sbin/arp -an
python3 arp_fixture_check.py
import ipaddress
import json
import re
# Synthetic macOS arp -an and Linux ip -j -4 neigh show output.
ARP_LINES = """? (192.0.2.10) at a:b:c:d:e:f on en0 ifscope [ethernet]
? (192.0.2.10) at 02:00:00:00:00:20 on en1 ifscope [ethernet]
? (192.0.2.11) at (incomplete) on en0 ifscope [ethernet]
"""
NEIGH_JSON = """[
{"dst":"192.0.2.10","dev":"eth0","lladdr":"02:00:00:00:00:10","state":["PERMANENT"]},
{"dst":"192.0.2.10","dev":"eth1","lladdr":"02:00:00:00:00:20","state":["STALE"]},
{"dst":"192.0.2.11","dev":"eth0","state":["INCOMPLETE"]},
{"dst":"192.0.2.12","dev":"eth0","state":["FAILED"]}
]"""
def parse_arp(text):
result = {}
pattern = r"\S+ \(([0-9.]+)\) at (\(incomplete\)|[0-9a-fA-F:]+) on (\S+)(?: .*)?"
for line in text.splitlines():
if not line.strip():
continue
match = re.fullmatch(pattern, line)
if not match:
raise ValueError("not a supported macOS ARP row")
ip, mac, interface = match.groups()
ip = str(ipaddress.IPv4Address(ip))
if mac == "(incomplete)":
mac = None
else:
parts = mac.split(":")
if len(parts) != 6 or any(not re.fullmatch(r"[0-9a-fA-F]{1,2}", p) for p in parts):
raise ValueError("invalid Ethernet address")
mac = ":".join(f"{int(part, 16):02x}" for part in parts)
result[(ip, interface)] = {"mac": mac}
return result
def parse_neigh(text):
result = {}
for entry in json.loads(text):
key = (str(ipaddress.IPv4Address(entry["dst"])), entry["dev"])
result[key] = {"mac": entry.get("lladdr"), "state": entry["state"]}
return result
if __name__ == "__main__":
arp = parse_arp(ARP_LINES)
neigh = parse_neigh(NEIGH_JSON)
assert len(arp) == 3 and len(neigh) == 4, "interface identity was lost"
assert arp[("192.0.2.10", "en0")]["mac"] == "0a:0b:0c:0d:0e:0f"
assert arp[("192.0.2.10", "en1")]["mac"] == "02:00:00:00:00:20"
assert arp[("192.0.2.11", "en0")]["mac"] is None
assert neigh[("192.0.2.10", "eth0")]["state"] == ["PERMANENT"]
assert neigh[("192.0.2.10", "eth1")]["state"] == ["STALE"]
assert neigh[("192.0.2.11", "eth0")] == {"mac": None, "state": ["INCOMPLETE"]}
assert neigh[("192.0.2.12", "eth0")] == {"mac": None, "state": ["FAILED"]}
assert parse_arp("") == parse_neigh("[]") == {}
try:
parse_arp("? (192.0.2.1) at aa:bb on en0")
except ValueError:
pass
else:
raise AssertionError("malformed MAC accepted")
print("PASS: interface keys, MAC normalization, unresolved states, and empty tables")
Expect PASS: interface keys, MAC normalization, unresolved states, and empty tables. The (IP, interface) keys preserve different links, and malformed MAC addresses are rejected. This is a parser exercise, not packet-level verification of ARP or a complete parser for every platform. Real incident snapshots should retain route, interface, MAC, state, and capture timestamps so resolution failure remains distinguishable from frames lost after successful resolution.
Further reading
Sources
- RFC 826: An Ethernet Address Resolution Protocol, ARP packet exchange and address mapping, checked 2026-09-09.
- RFC 5227: IPv4 Address Conflict Detection, probing and gratuitous ARP considerations, checked 2026-09-09.
- arp(8) manual, the legacy ARP table command, checked 2026-09-09.
- ip-neighbour(8) manual, neighbor states and operations, checked 2026-09-09.