Chapter 05 · Build macOS
Securing the MacBook Pro
The Mac is the interesting case, because macOS ships an SSH server that is deliberately awkward to customise, and because a development machine accumulates listening services far faster than anyone expects. Both problems are solved here.
Baseline, before anything network-facing
Full-disk encryption is the control that matters most, and it is the one people postpone. Without it, every key and credential on the machine is readable by anyone who removes the drive or boots from external media.
# FileVault — must say "FileVault is On."
sudo fdesetup status
# System Integrity Protection — must say "enabled"
csrutil status
# Gatekeeper — must say "assessments enabled"
spctl --status
# Require the password immediately when the screen locks
defaults write com.apple.screensaver askForPassword -int 1
defaults write com.apple.screensaver askForPasswordDelay -int 0
A password manager on your phone, or paper in a drawer. A recovery key saved only on the encrypted disk it unlocks is not a recovery key.
Audit what is already listening
Do this before adding SSH, because it usually produces a surprise. A development Mac tends to accumulate databases, caches and dev servers, many of which bind to every interface by default:
# Everything listening on ALL interfaces — the ones that matter
netstat -an -p tcp | awk '$NF=="LISTEN" && $4 ~ /^\*\./ {print $4}' | sort -u
# Map those ports to actual processes
sudo lsof -nP -iTCP -sTCP:LISTEN
A real result from a working developer machine:
*.443 *.445 *.53 *.5432 *.5433
*.6379 *.80 *.8000 *.88 *.5003
That is SMB, DNS, two PostgreSQL instances, Redis, and several web servers — all
reachable from any café Wi-Fi the laptop joins. Redis is the urgent one: default builds have no
authentication, and an attacker who can reach it can use CONFIG SET dir and
dbfilename to write files as your user, including into ~/.ssh/.
- Bound to
0.0.0.0The default in a startling number of tools. It does not mean "the network you are on" — it means every interface the machine has now or acquires later. Join a café network and the port arrives there with you. - Bound to
127.0.0.1The loopback interface is not a network. Packets addressed to it are handed straight back to the kernel and never reach a network card, so there is no path in from outside — not blocked, absent. - Bound to the tailnet address
100.117.202.65sits onutun, the virtual interface Tailscale creates. Traffic to it arrives inside WireGuard, already authenticated as a device you enrolled. - 1 · The café network reaches inSomeone on the same subnet scans it — that costs them one command — and finds 6379 open. Default Redis builds have no authentication whatsoever, so "found it" and "logged in" are the same event.
- 1 · The café network is refusedThe scan finds nothing, because the port genuinely is not listening on that interface. This is stronger than a firewall rule: there is no rule to misconfigure, no ordering to get wrong, no state to reload.
- 2 · And so does everyone elseYour phone works.
redis-cliworks. That is what makes this so easy to leave in place — from where you sit, everything is fine, and nothing distinguishes your laptop from the stranger's. - 2 · Local tools keep working; your phone does notYour app and
redis-cliwere always connecting over loopback, so nothing changes for them. Remote access goes through SSH instead — you log in to the machine, and the tool runs there. - 2 · Your devices reach it; loopback stopsThe useful half of this is obvious. The half that catches people: a service bound only to the tailnet address is no longer on
127.0.0.1, so anything hard-coded to localhost breaks. For sshd that is fine; for a database it usually is not. - 3 · What that actually costsAn unauthenticated Redis is a file-write primitive.
CONFIG SET diranddbfilenamepoint it at~/.ssh/authorized_keys,SAVEwrites the attacker's key there as your user, and they log in over the SSH you just spent a chapter hardening. - 3 · The right answer for stateful servicesDatabases, caches and dev servers belong here. Nothing you run locally needs to be reachable from another machine, and the moment one does, that is a decision to make on purpose rather than a default to inherit.
- 3 · The right answer for sshdThis is the whole design of the guide in one line of config. The port is reachable by devices you enrolled in the tailnet and does not exist anywhere else — which is why the rest of this chapter goes to the trouble of running its own sshd.
127.0.0.1. SSH binds to the tailnet address. Nothing a developer
runs needs 0.0.0.0, and the fact that it is the default for so many tools is
precisely why this audit is worth doing.
Fix the ones that matter, then turn on the firewall as a backstop:
# Redis: bind 127.0.0.1 in redis.conf
# PostgreSQL: listen_addresses = 'localhost' in postgresql.conf
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate on
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setstealthmode on
Stealth mode drops ICMP, so the Mac stops answering pings — including from your own devices. That is usually fine, but remember it before spending an afternoon debugging "the Mac is down." The application firewall may also prompt about, or block, dev servers you actually want reachable from your phone on the LAN; reaching them over the tailnet instead avoids the whole question.
Getting Tailscale onto the Mac
Everything from here binds sshd to a Tailscale address, so Tailscale has to exist first. macOS is the one platform where which Tailscale you install changes what the machine can do, and the download page does not make the differences obvious.
| Build | Up before login? | Can be a Tailscale SSH server? |
|---|---|---|
| Mac App Store the one most people have |
No — sandboxed, starts when you log in | No |
Standalone .pkgfrom pkgs.tailscale.com |
No | No |
Open-source CLItailscale + tailscaled |
Yes — a real system daemon | Yes |
For this guide, the App Store app is the right default. The Mac runs OpenSSH rather than Tailscale SSH (chapter 01 explains why), so the only thing the App Store build costs you is that the tailnet comes up after login rather than at boot. That is precisely the boot-time race described later in this chapter, and it self-heals.
# The default path: install Tailscale from the Mac App Store,
# then sign in with the same account as your other machines.
# The App Store app ships its CLI inside the bundle, not on PATH.
# Add this to ~/.zshrc:
alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale"
If you would rather the tailnet be up at boot — so the Mac is reachable after a power cut without someone typing a login password — install the open-source daemon instead:
brew install tailscale # the FORMULA, not the cask
sudo brew services start tailscale # runs tailscaled as a system daemon
sudo tailscale up
brew install tailscale gives you the open-source CLI daemon.
brew install --cask tailscale gives you the GUI app. They are not the same package
and installing both leaves two daemons contending for the same interface. Pick one; if you
switch later, remove the other first.
Whichever you chose, get the address the rest of this chapter needs:
tailscale status # this Mac should be listed and online
tailscale ip -4 # e.g. 100.117.202.65
tailscale ip -6 # e.g. fd7a:115c:a1e0:ab12:4843:cd96:6475:ca41
You need both. Tailscale gives every device an address in each family, and
MagicDNS publishes both, so a client resolving macbook may well try the IPv6 one
first.
They go into sshd_config, and a typo there produces a job that fails to bind and
retries forever — which looks identical to the perfectly normal boot-time race described later.
Two very different problems with one symptom, so start from correct addresses.
Choosing how to run sshd
macOS gives you Remote Login in System Settings, which loads Apple's socket-activated
com.openssh.sshd job on port 22. It works, but it offers no control over the bind
address, so the port is exposed on every network the laptop joins. Since our whole design is
"reachable on the tailnet and nowhere else", we run our own launchd daemon instead.
If you run a custom daemon, leave Remote Login off. Two sshd instances with different configurations on the same machine is a genuinely confusing state to debug, and the Apple one will happily listen on 0.0.0.0 while you believe you are tailnet-only.
# Confirm Apple's job is not loaded
sudo systemsetup -getremotelogin
launchctl print system/com.openssh.sshd 2>&1 | head -3
# "Could not find service" is the answer you want
The configuration
macOS keeps /etc/ssh/sshd_config with Include /etc/ssh/sshd_config.d/* at
the top, and sshd takes the first value it sees for each keyword — so a drop-in file
overrides the main config. That is what we want.
# --- exposure: tailnet only ---
# Both families, or half your clients get "connection refused".
Port 22
ListenAddress 100.x.y.z # `tailscale ip -4`
ListenAddress fd7a:115c:a1e0:ab12:4843:cd96:6475:ca41 # `tailscale ip -6`
# --- authentication ---
# UsePAM yes is required on macOS: it runs /etc/pam.d/sshd, which contains
# account required pam_sacl.so sacl_service=ssh
# With UsePAM no that SSH access control list is skipped entirely.
UsePAM yes
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PermitRootLogin no
AllowUsers yourname
MaxAuthTries 4
# --- reduce session capability ---
AllowAgentForwarding no
X11Forwarding no
PermitTunnel no
GatewayPorts no
AllowTcpForwarding yes
# --- survive flaky mobile networks ---
ClientAliveInterval 30
ClientAliveCountMax 6
TCPKeepAlive yes
LogLevel VERBOSE
AuthorizedKeysFile .ssh/authorized_keys
Before relying on UsePAM yes, confirm you are actually permitted by that access list
— otherwise you will turn on enforcement and lock yourself out:
dseditgroup -o checkmember -m "$(whoami)" com.apple.access_ssh
# "yes ... is a member of com.apple.access_ssh"
ListenAddress line is a trap
With no ListenAddress at all, sshd listens on every address in both families. The
moment you add one, that stops — sshd now listens on exactly what you listed and
nothing else, and "nothing else" includes the whole of IPv6.
That matters because MagicDNS publishes an AAAA record alongside the
A record, and a client resolving macbook will often try the IPv6
address first. It reaches the host, finds no listener, and returns
Connection refused — from a machine you can ping, on a tailnet that is working
perfectly. The diagnostic ladder in chapter 12 will not
catch it either, because testing the port with nc against the
100.x address succeeds.
Bind both, as above. If you have some reason to stay on IPv4 only, make it explicit on the
client side with AddressFamily inet in ~/.ssh/config — never leave the
two ends disagreeing about which family to use.
The launchd daemon
Two flags here are not optional, and both are easy to omit.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.local.sshd</string>
<key>ProgramArguments</key>
<array>
<string>/usr/sbin/sshd</string>
<string>-D</string>
<string>-e</string>
<string>-f</string>
<string>/etc/ssh/sshd_config</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>ThrottleInterval</key> <integer>10</integer>
<key>StandardErrorPath</key>
<string>/var/log/com.local.sshd.err</string>
<key>UserName</key> <string>root</string>
</dict>
</plist>
-D and -e matter
-D stops sshd from detaching. Without it, sshd forks and the
process launchd is tracking exits immediately. launchd concludes the job died, and
KeepAlive restarts it — but the orphaned original still holds the port, so the
replacement exits with "address already in use." The symptom is a restart counter in
the hundreds and last exit code = 255. Check yours with
launchctl print system/com.local.sshd; a healthy job shows runs = 1.
-e sends logs to stderr so StandardErrorPath captures
them. Without it sshd logs to syslog, macOS unified logging swallows it, and your log file
stays zero bytes exactly when you need it.
- launchd starts the job
RunAtLoadfires and launchd executes/usr/sbin/sshd. It now has one pid it considers to be the job: if that pid exits, the job has ended, andKeepAlive truemeans start it again. - launchd starts the jobIdentical so far. The only difference is one flag in
ProgramArguments, and it changes what the process does about a second from now. - 1 · sshd daemonisesLeft to itself, sshd does the traditional Unix thing: it forks a background copy and the original returns immediately. The background copy binds port 22 and works perfectly. The pid launchd is tracking, meanwhile, has exited.
- 1 · sshd stays put
-Dtells sshd not to detach. The process launchd started is the one that binds the port and keeps running, which is exactly the contract every process supervisor expects. - 2 · launchd concludes the job diedFrom launchd's side this is indistinguishable from a crash — it never learns about the fork.
KeepAlivedoes its job and schedules a restart, throttled to one attempt every ten seconds. - 2 · launchd has nothing to doThe process is alive, so
KeepAlivestays quiet. It only acts if sshd actually dies — which is what you wanted it for in the first place. - 3 · The restart loopThe replacement tries to bind :22 and cannot: the orphaned daemon from the first launch still holds it. It exits 255, launchd waits ten seconds and tries again, forever. SSH keeps working — the orphan is serving it — so the only symptom is a restart counter in the hundreds and a log full of "address already in use."
- 3 · One process, one jobThe master sshd forks a child per incoming connection, as it always has, but those children are session workers and come and go. The master itself never exits, so
runsstays at 1 — which is the single number to check when you want to know whether this is set up correctly.
-D
you can still SSH in, because the orphan is answering. Nothing is obviously broken until you
look at launchctl print and find the job has restarted two hundred times, or you
reboot and get a different orphan holding the port than the one launchd thinks it started.
The boot-time race, and why it is fine
ListenAddress 100.117.202.65 refers to an address on utun that does not
exist until Tailscale starts — which, with the GUI app, means after you log in. At boot, sshd
cannot bind and exits. KeepAlive plus ThrottleInterval 10 retries every
ten seconds and succeeds on its own moments after Tailscale comes up.
This is a deliberate trade. The alternative — binding every interface and filtering in software — would let sshd start at boot, but it puts the port on every network you join. Binding to the tailnet address means the port genuinely does not exist elsewhere, which is worth a few seconds of self-healing retry after a reboot.
- The machine bootslaunchd loads
com.local.sshdbecauseRunAtLoadsays so. At this point the only interfaces that exist are loopback and the physical NIC. There is noutun, because Tailscale has not started — with the GUI app it starts after you log in. - 1 · The bind failssshd asks the kernel for
100.117.202.65:22and is told Cannot assign requested address, because no interface owns that address. It logs the error and exits. Nothing is broken; it was asked for something that is not there yet. - 2 · launchd retries, patiently
KeepAlive truerestarts the job whenever it exits, andThrottleInterval 10spaces those attempts ten seconds apart. So the log fills with the same line every ten seconds. This is the part that looks alarming the first time you see it and is entirely expected. - 3 · Tailscale comes upYou log in, the Tailscale app starts, it brings up
utun3and assigns your tailnet address to it. The address sshd has been asking for now exists. - 4 · The next retry succeedsWithin ten seconds the job starts again, the bind works, and sshd is listening on the tailnet address and nowhere else. From here it stays up; the loop was self-healing and needed nothing from you.
0.0.0.0 and filtering in software — and then the port would exist on every network
the laptop joins, protected by a rule you have to keep correct. Binding the tailnet address
means the port genuinely is not there, at the cost of a few seconds of retry after a reboot.
If you want it up before login, install the open-source tailscaled daemon
(brew install tailscale — the formula) instead of the GUI app, as described at the
top of this chapter.
Load and verify
sudo sshd -t -f /etc/ssh/sshd_config # validate FIRST, always
sudo launchctl bootstrap system /Library/LaunchDaemons/com.local.sshd.plist
# Expect TWO lines — the tailnet IPv4 and the tailnet IPv6. Never *.22
netstat -an -p tcp | grep '\.22 ' | grep LISTEN
# And prove the v6 path end to end, not just that something is bound
ssh -6 macbook 'echo ipv6 ok'
# Healthy job: runs = 1, "never exited"
sudo launchctl print system/com.local.sshd | grep -E 'state|runs|last exit'
# The port must be REFUSED on your LAN address
nc -z -G 3 "$(ipconfig getifaddr en0)" 22 && echo "EXPOSED" || echo "correctly refused"
A successful handshake is not the same as a working session. Connect and hold for a while
before declaring victory: ssh macbook 'sleep 15; echo ok'. And remember that
last reports durations as HH:MM — any session under a minute displays as
(00:00), which is not evidence that it died instantly.
Full Disk Access — a deliberate omission
A hand-rolled sshd does not inherit the Full Disk Access grant that Apple's Remote Login gets.
Remote sessions will hit Operation not permitted on Desktop, Documents and
Downloads.
Granting it (System Settings → Privacy & Security → Full Disk Access → add
/usr/sbin/sshd) fixes that, but it also means any SSH session has unrestricted
access to your protected data. Leave it off unless you genuinely need those directories
remotely; keeping your work in ~/Developer or similar avoids the question entirely.
Dev environment
brew install tmux herdr mosh
# Homebrew's PATH is set in ~/.zprofile, which only LOGIN shells read.
# So `ssh mac 'tmux ls'` fails with "command not found" while an
# interactive `ssh mac` then `tmux ls` works. Use the full path in
# non-interactive commands:
ssh macbook -t '/opt/homebrew/bin/tmux new -A -s phone'
That PATH asymmetry catches everyone once. Non-interactive SSH commands read only
~/.zshenv; login shells read ~/.zprofile and ~/.zshrc.
Reaching a dev server from your phone
This chapter told you to bind every service to 127.0.0.1, and
chapter 03 told you Mosh cannot forward ports. Put those together
and there is an obvious question left hanging: you have a Vite server running on
localhost:5173, you are holding a phone, and there is no path between them. A shell
is not the only thing you want remotely.
Three answers, best first. Note that none of them involves widening the bind address and hoping.
1 · tailscale serve — the clean one
Tailscale can publish a loopback port to your tailnet, over HTTPS, with a real certificate and no
browser warning. The dev server itself does not move — it stays on 127.0.0.1, exactly
where the audit above wanted it.
# The dev server is unchanged, still bound to loopback
npm run dev # 127.0.0.1:5173
# Publish it — to the tailnet, and only the tailnet
tailscale serve --bg 5173
# Check what you have published, and take it all down again
tailscale serve status
tailscale serve reset
Then open https://macbook.your-tailnet.ts.net on the phone. This needs HTTPS
certificates enabled once for the tailnet, under Admin console → DNS →
HTTPS Certificates.
serve is private · funnel is the public internet
tailscale funnel is the same command family with one critical difference: it
publishes to the whole internet rather than to your tailnet. The two are one word apart and the
blast radius is not remotely comparable. If you have spent this chapter making sure a port
exists nowhere but the tailnet, do not hand it to Funnel by muscle memory. Run
tailscale serve status when unsure — it tells you which one is active.
serve was reworked and older write-ups show a longer
tailscale serve https / http://127.0.0.1:5173 form. Check
tailscale serve --help on your installed version rather than trusting the shape
above.
2 · ssh -L — works for anything, not just HTTP
A plain SSH tunnel needs no Tailscale features and does not care what protocol is on the other end, which makes it the right tool for a database client or anything that is not a web page:
# From the phone, in a second Blink session
ssh -L 5173:127.0.0.1:5173 macbook
Then http://localhost:5173 in the phone's browser. The catch is the one chapter 03
named: Mosh cannot carry this, so it has to be a plain SSH session running
alongside your Mosh one. That is fine — a tunnel does not need to survive the train tunnel. When
it drops, you reopen it, and your actual work is still sitting in tmux.
This also needs AllowTcpForwarding yes, which the config earlier in this chapter sets
deliberately. If you tighten that to no later, this option and
ProxyJump both stop working.
3 · Bind to the tailnet address — deliberately, or not at all
The last resort, for a service that genuinely must be reachable with no tunnel in the way:
npm run dev -- --host 100.117.202.65
Two costs, both easy to forget. It needs an accept rule for that port in your ACL
policy (chapter 01) or the tailnet drops the packets before the
Mac sees them. And anything hard-coded to localhost — your test runner, a sibling
service, a proxy config — stops resolving. Bind both addresses or neither.
HTTP that you want to look at on the phone: tailscale serve. Anything else, or
anything short-lived: ssh -L. Widening the bind address is for services you have
decided are part of your tailnet's surface on purpose — and that decision belongs in the ACL
policy, where you can see it next year.
Checklist
- FileVault on, recovery key stored off the machine.
- SIP enabled and Gatekeeper assessments enabled.
- Screen lock requires the password immediately.
- Every listening service audited; databases and caches moved to
127.0.0.1. - Application firewall on.
- Tailscale installed and signed in; exactly one build, and you know which.
tailscale ip -4returns this Mac's address. - Remote Login off; exactly one sshd daemon running.
ListenAddressset for both the Tailscale IPv4 and IPv6 address;ssh -6proven working, and the port verified refused on the LAN address.UsePAM yes, and membership ofcom.apple.access_sshconfirmed.- Plist includes
-Dand-e;runsstays at 1. sshd -tpasses and a held session was proven to survive.- tmux, Herdr and mosh installed.
The commands in this guide change firewall and login settings, and can lock you out of a machine. Practise on something disposable first. Everything here is provided as is, with no warranty — you accept the risk of running it. Read the disclaimer.