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
Store the FileVault recovery key somewhere off the machine

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/.

redis-server is bound to
One config line decides who in the world can talk to your database the default for most tools — and for Redis, the dangerous one loopback: packets never reach a network card at all the address WireGuard put on utun — your devices, nobody else MacBook Pro redis-server · port 6379 no password · default build bind 0.0.0.0 bind 127.0.0.1 bind 100.117.202.65 café Wi-Fi a stranger on the same subnet your LAN the TV, the guest laptop your tailnet iPhone, VPS — signed in this machine redis-cli over loopback CONNECTED CONNECTED refused refused connected connected refused connected connected refused 0.0.0.0 means every interface — including the ones you have not joined yet. Nothing warns you. netstat shows *.6379 and that is the only clue you get. 127.0.0.1 is the loopback interface. Packets to it never touch a network card. Off-machine reachability is not filtered here — it simply does not exist. 100.117.202.65 lives on utun, the interface Tailscale creates. It exists only while Tailscale is up — which matters at boot. See below. The stranger connects. A default Redis build asks for no password. So does every other device on that network, and on the next one you join. Connection refused. The port is not open on that interface at all. There is no firewall rule to get wrong, because there is nothing to filter. Everything reaches it, which is exactly the problem: you cannot tell these apart. One bind address, four very different sets of people. Local tools are unaffected — they were always talking to 127.0.0.1. Your phone is refused too. Reach it by SSHing in first, not by widening the bind. Your own devices reach it. Every café in the world does not. Note the cost: tools hard-coded to 127.0.0.1 stop working. Bind both, or neither. CONFIG SET dir /Users/you/.ssh · CONFIG SET dbfilename authorized_keys · SAVE No exploit required. Redis writes the attacker's public key as you, and now they SSH in. Correct for databases, caches and dev servers. Redis, PostgreSQL, Rails, Vite — none of them need to be reachable off the machine. Correct for sshd, and for the few things you genuinely want remote. Reachable by devices you signed into the tailnet, refused by everyone else.
  1. 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.
  2. 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.
  3. Bound to the tailnet address100.117.202.65 sits on utun, the virtual interface Tailscale creates. Traffic to it arrives inside WireGuard, already authenticated as a device you enrolled.
  4. 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.
  5. 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.
  6. 2 · And so does everyone elseYour phone works. redis-cli works. 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.
  7. 2 · Local tools keep working; your phone does notYour app and redis-cli were 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.
  8. 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.
  9. 3 · What that actually costsAn unauthenticated Redis is a file-write primitive. CONFIG SET dir and dbfilename point it at ~/.ssh/authorized_keys, SAVE writes the attacker's key there as your user, and they log in over the SSH you just spent a chapter hardening.
  10. 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.
  11. 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.
Every listening service belongs on loopback or on the tailnet address. Databases and caches bind to 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 and local development

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.

BuildUp 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 .pkg
from pkgs.tailscale.com
No No
Open-source CLI
tailscale + 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
The Homebrew formula and the Homebrew cask are different things

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.

Write both addresses down before you continue

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.

Pick one — never both

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.

/etc/ssh/sshd_config.d/tailscale.conf
# --- 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"
One 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.

/Library/LaunchDaemons/com.local.sshd.plist
<?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>
Why -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.

ProgramArguments
launchd KeepAlive true sshd the tracked pid TCP :22 100.117.202.65 sshd (forked) launchd cannot see it bootstrap · exec /usr/sbin/sshd fork() · sshd detaches binds :22 the tracked pid exits job died → KeepAlive restarts ThrottleInterval 10 · ten seconds restart #147 bind 100.117.202.65:22 Address already in use · exit 255 binds :22 and stays in the foreground still running · nothing to restart session child one per connection forks per session the master never exits
  1. launchd starts the jobRunAtLoad fires 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, and KeepAlive true means start it again.
  2. 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.
  3. 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.
  4. 1 · sshd stays put-D tells 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.
  5. 2 · launchd concludes the job diedFrom launchd's side this is indistinguishable from a crash — it never learns about the fork. KeepAlive does its job and schedules a restart, throttled to one attempt every ten seconds.
  6. 2 · launchd has nothing to doThe process is alive, so KeepAlive stays quiet. It only acts if sshd actually dies — which is what you wanted it for in the first place.
  7. 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."
  8. 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 runs stays at 1 — which is the single number to check when you want to know whether this is set up correctly.
The failure is quiet, which is what makes it worth a diagram. Without -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.

sshd is asked to bind an address that does not exist yet ifconfig lo0 127.0.0.1 en0 192.168.1.42 utun3 Tailscale is not up yet 100.117.202.65 sshd ListenAddress 100.117.202.65 cannot assign address KeepAlive · retry 10s LISTEN on :22 launchd RunAtLoad · KeepAlive ThrottleInterval 10 attempt 1 · failed attempt 2 · failed attempt 3 · failed attempt 4 · running retry bind boot t+10 t+20 you log in bound
  1. The machine bootslaunchd loads com.local.sshd because RunAtLoad says so. At this point the only interfaces that exist are loopback and the physical NIC. There is no utun, because Tailscale has not started — with the GUI app it starts after you log in.
  2. 1 · The bind failssshd asks the kernel for 100.117.202.65:22 and 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.
  3. 2 · launchd retries, patientlyKeepAlive true restarts the job whenever it exits, and ThrottleInterval 10 spaces 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.
  4. 3 · Tailscale comes upYou log in, the Tailscale app starts, it brings up utun3 and assigns your tailnet address to it. The address sshd has been asking for now exists.
  5. 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.
This is a trade, not a bug. You could make sshd start cleanly at boot by binding 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"
Prove a session actually holds

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.

The syntax moved

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.

The rule of thumb

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 -4 returns this Mac's address.
  • Remote Login off; exactly one sshd daemon running.
  • ListenAddress set for both the Tailscale IPv4 and IPv6 address; ssh -6 proven working, and the port verified refused on the LAN address.
  • UsePAM yes, and membership of com.apple.access_ssh confirmed.
  • Plist includes -D and -e; runs stays at 1.
  • sshd -t passes 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.