macOS port 5000 "Address already in use" (Errno 48) or 403 Forbidden on localhost: ControlCenter (AirTunes) has it, and `netstat -tlnp` doesn't work
A local dev server on port 5000 won’t start on this Mac, and nothing you started is using the port:
$ python3 -m http.server 5000
Traceback (most recent call last):
...
OSError: [Errno 48] Address already in use
Or the server does start (bound to one address, as below), and curl gets
an empty 403 Forbidden that your app never produced:
$ curl -i http://localhost:5000/
HTTP/1.1 403 Forbidden
Content-Length: 0
Server: AirTunes/950.7.1
X-Apple-ProcessingTime: 1
X-Apple-RequestReceivedTimestamp: 1195481073
(The two X-Apple- values differed between my requests.) Server: AirTunes is
the clue. On this Mac, macOS’s own ControlCenter
process listens on ports 5000 and 7000, on all interfaces, for IPv4 and
IPv6. Below is how I found that without netstat -tlnp, why 127.0.0.1 and
localhost gave different answers, and what I changed.
netstat -tlnp and ss don’t work here
The Linux way to ask “who is listening on this port” fails on macOS:
$ netstat -tlnp
-tlnp: option requires an argument -- p
Usage: netstat [-AaLlnW] [-f address_family | -p protocol]
netstat [-gilns] [-f address_family]
netstat -i | -I interface [-w wait] [-abdgRtS]
netstat -s [-s] [-f address_family | -p protocol] [-w wait]
netstat -i | -I interface -s [-f address_family | -p protocol]
netstat -m [-m]
netstat -r [-Aaln] [-f address_family]
netstat -rs [-s]
$ echo $?
64
$ ss -tlnp
zsh: command not found: ss
On macOS’s netstat, -p takes a protocol name (-p tcp), and -l
means “print full IPv6 address”, not “listening” (man netstat). So even
netstat -tln runs, but it lists your established connections too, not
just listeners.
netstat and lsof live in /usr/sbin. A normal Terminal has that on
PATH; if a script says lsof: command not found, call /usr/sbin/lsof.
Find the listener with lsof
$ lsof -nP -iTCP:5000 -sTCP:LISTEN
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
ControlCe 670 me 11u IPv4 0xb734fa7e0696bb17 0t0 TCP *:5000 (LISTEN)
ControlCe 670 me 12u IPv6 0xf978d71366c0e326 0t0 TCP *:5000 (LISTEN)
(PIDs and DEVICE values will differ on your Mac.) ControlCe is
lsof cutting the command name to 9 characters. The full path:
$ ps -o comm= -p "$(lsof -t -iTCP:5000 -sTCP:LISTEN)"
/System/Library/CoreServices/ControlCenter.app/Contents/MacOS/ControlCenter
Port 7000 answers the same way:
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7000/
403
The flags matter:
-nand-Pprint numbers. Without-P, port 8765 showed up aslocalhost:ultraseek-httpin my test (a name from/etc/services), which makes agrep 8765miss it.-sTCP:LISTENkeeps only listening sockets. Without it,-i :PORTalso lists every client connected to that port (see thekilltrap below).
netstat can show the owner too, but only with -v, in a process:pid
column far to the right. Note the port comes after a dot, not a colon:
$ netstat -anv -p tcp | awk '$4 ~ /\.5000$/ && /LISTEN/ {print $1, $4, $11}' | sort
tcp4 *.5000 ControlCenter:670
tcp6 *.5000 ControlCenter:670
In my setup, a listener owned by root showed up in netstat -anv but not
in lsof run as my user (I did not try lsof with
sudo). If lsof shows nothing and the bind still fails, check
netstat -anv.
Why 127.0.0.1 works and localhost gets 403
ControlCenter is bound to the wildcard address (*:5000). A socket bound to
a specific address can still take the same port if it sets SO_REUSEADDR.
This script tries each address with and without it:
# bindtest.py
import socket, sys
host, reuse = sys.argv[1], sys.argv[2] == "reuse"
fam = socket.AF_INET6 if ":" in host else socket.AF_INET
s = socket.socket(fam, socket.SOCK_STREAM)
if reuse:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind((host, 5000))
print(host, "reuse" if reuse else "no-reuse", "-> bound")
except OSError as e:
print(host, "reuse" if reuse else "no-reuse", "->", e)
$ for h in 127.0.0.1 0.0.0.0 ::1 ::; do for r in reuse no; do python3 bindtest.py $h $r; done; done
127.0.0.1 reuse -> bound
127.0.0.1 no-reuse -> [Errno 48] Address already in use
0.0.0.0 reuse -> [Errno 48] Address already in use
0.0.0.0 no-reuse -> [Errno 48] Address already in use
::1 reuse -> bound
::1 no-reuse -> [Errno 48] Address already in use
:: reuse -> [Errno 48] Address already in use
:: no-reuse -> [Errno 48] Address already in use
Python’s http.server sets SO_REUSEADDR, so binding it to 127.0.0.1
works. But now two processes own port 5000, and which one you reach depends
on the address the client uses:
$ python3 -m http.server 5000 --bind 127.0.0.1 >/dev/null 2>&1 &
$ P=$!; sleep 1
$ curl -s -o /dev/null -w '127.0.0.1 -> %{http_code}\n' http://127.0.0.1:5000/
127.0.0.1 -> 200
$ curl -s -o /dev/null -w 'localhost -> %{http_code} %{remote_ip}\n' http://localhost:5000/
localhost -> 403 ::1
$ kill $P
curl resolved localhost to ::1 first. Nothing of mine listens on
::1, so ControlCenter’s IPv6 socket answered.
Node does the opposite. In my test, listen(5000) with no host failed with
EADDRINUSE, and listen(5000, "localhost") bound only ::1:
$ node -e 'const s=require("http").createServer((q,r)=>r.end("node\n")); s.listen(5000,"localhost",()=>console.log("bound", s.address()))' &
bound { address: '::1', family: 'IPv6', port: 5000 }
$ P=$!; sleep 1
$ curl -s -o /dev/null -w '127.0.0.1 -> %{http_code}\n' http://127.0.0.1:5000/
127.0.0.1 -> 403
$ curl -s -o /dev/null -w 'localhost -> %{http_code} %{remote_ip}\n' http://localhost:5000/
localhost -> 200 ::1
$ kill $P
So the same app works or gets 403 depending on whether you type
127.0.0.1 or localhost. In both cases the 403 has Content-Length: 0
and no log line in your server, because the request never reached it.
The fix: use another port
I moved the dev servers off 5000 (and 7000). Check that the new port is
free first: lsof prints nothing and exits 1:
$ lsof -nP -iTCP:8765 -sTCP:LISTEN; echo "exit $?"
exit 1
$ python3 -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &
$ P=$!; sleep 1
$ curl -s -o /dev/null -w 'localhost -> %{http_code} %{remote_ip}\n' http://localhost:8765/
localhost -> 200 127.0.0.1
$ kill $P
With nothing on ::1:8765, curl tried ::1, got refused, and fell back
to 127.0.0.1. That fallback is why localhost usually “just works”, and
why it fails on 5000: there, ::1 does answer, with AirTunes.
If you need port 5000, the Server: AirTunes header points at AirPlay. The
usual advice is to switch off AirPlay Receiver in System Settings. I did
not change that setting on this Mac, so I haven’t tested it.
Don’t kill $(lsof -t -i :PORT)
lsof -t prints only PIDs, which makes kill "$(lsof -t …)" tempting. But
without -sTCP:LISTEN, -i :PORT matches both ends of every connection
on that port. With a server on 8765 and one client holding a connection open:
$ python3 -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &
$ S=$!; sleep 1
$ python3 -c 'import socket,time; s=socket.create_connection(("127.0.0.1",8765)); time.sleep(120)' &
$ C=$!; sleep 1
$ lsof -nP -i :8765
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
Python 18376 me 4u IPv4 0xaf925289b76679b8 0t0 TCP 127.0.0.1:8765 (LISTEN)
Python 18376 me 5u IPv4 0x7570a9b1b262b905 0t0 TCP 127.0.0.1:8765->127.0.0.1:64104 (ESTABLISHED)
Python 18424 me 3u IPv4 0x47fceffcda9c39af 0t0 TCP 127.0.0.1:64104->127.0.0.1:8765 (ESTABLISHED)
$ lsof -t -i :8765
18376
18424
$ lsof -t -iTCP:8765 -sTCP:LISTEN
18376
$ kill $C $S
lsof -t -i :8765 returned the client’s PID too. A browser tab with that
page open would be on that list. On port 5000, “the thing on the port” is
ControlCenter itself. Look at the lsof output before killing anything, and
kill the PID you started ($!), not whatever matches.
Checklist
Errno 48/EADDRINUSEon 5000 or 7000 on macOS, or an empty 403 withServer: AirTunes: ControlCenter has the port (*:5000, IPv4 and IPv6).netstat -tlnpexits 64 here;ssdoesn’t exist. Uselsof -nP -iTCP:PORT -sTCP:LISTEN, ornetstat -anv -p tcpand read theprocess:pidcolumn (port after a dot).lsofwithoutsudodidn’t show a root-owned listener in my setup;netstat -anvdid.- Binding
127.0.0.1:5000(withSO_REUSEADDR) succeeds, but thenlocalhost→::1→ AirTunes 403. Node’s"localhost"binds::1, so127.0.0.1gets the 403 instead. - Fix: use another port, check it with
lsoffirst. Open the server at the same address it’s bound to. lsof -t -i :PORTincludes clients. Add-sTCP:LISTEN, read before killing, prefer the PID you started.
Tested on 2026-10-25 on macOS 26.5.2 (build 25F84, Apple silicon) with
/usr/sbin/lsof 4.91, /usr/sbin/netstat, /usr/bin/python3 3.8.2, Node
v24.14.1 and curl 8.7.1, in zsh 5.9 with
PATH=/usr/bin:/bin:/usr/sbin:/sbin. AirPlay Receiver was left at its
setting on this Mac (on: ControlCenter was listening); switching it off was not
tested. Linux, Intel Macs and older macOS versions were not tested.