Your Mac as a key fob — SSH keys that live in the Secure Enclave, open only the doors they're badged for, and take one touch to use.
getfob.app — website & install
A git push, gated by Touch ID. ssh myserver gets the same prompt, naming that host.
The private key is generated inside the Secure Enclave and never leaves it — no key file to steal, back up, or leak. What's on disk is an encrypted blob only your Mac's enclave can use, and every use needs Touch ID (or Apple Watch / password). On top of that, fob adds destination-aware prompts, per-host pinning, touch reuse, a tamper-evident audit log, and a read-only checkup of your SSH setup — as a menu-bar app plus a fob CLI, with zero third-party dependencies.
In practice that hardens the three things you do all day:
sshinto your servers — the box opens for your finger, not for a file someone could copy.git push/pullon GitHub (GitLab, Bitbucket, Codeberg…) — nothing on disk could push code as you.- Signing commits — the signature that makes GitHub show Verified comes from the enclave, so it can't be lifted and used to forge your name.
All three normally rest on the same thing: a private-key file in ~/.ssh. Whoever copies it — a backup, a stolen laptop, malware running as you — gets your servers, your pushes, and your signature. With fob there's no file to copy, and each use costs one touch.
It's the same mechanism either way: ssh myserver and git push both get a prompt naming the verified destination, the same pinning rules, and the same audit trail. GitLab, Bitbucket, Codeberg and any plain SSH host work exactly like GitHub.
- 🔐 Keys in the Secure Enclave — non-exportable; nothing usable on disk or in memory
- 👆 One touch per use — Touch ID, Apple Watch, or password
- 🚪 Your own servers —
sshany host with one touch; the prompt names the verified destination - 🐙 GitHub, GitLab & co. —
git push/pullauthenticates from the enclave; no key file that could push as you - ✍️ Touch-ID commit signing — sign git commits with a Secure Enclave key; GitHub/GitLab show Verified
- 🎯 Destination-aware prompts — see where you're connecting, cryptographically verified
- 📌 Per-host pinning — a key refuses every host but the one it's bound to
- ⏱️ Opt-in touch reuse — one touch covers a
git/rsyncburst - 🚚 Safe migration — moves an existing SSH host to fob alongside the old key; no cutover, no lockout
- 🩺 SSH checkup — a read-only hygiene report of your
~/.ssh: unencrypted or unused keys, risky config, and identity/signing footguns - 📜 Tamper-evident audit log — hash-chained record of every decision
- 🖥️ Menu-bar app + CLI — live activity feed, guided setup, zero dependencies
brew install --cask olivierzol/fob/fobThen open fob from the menu bar and turn on Launch at login.
On a non-admin or managed Mac? Homebrew installs to
/Applications(needs admin). To install into your own home folder with nosudo, add--appdir:brew install --cask olivierzol/fob/fob --appdir=~/ApplicationsMake it the default for future installs/upgrades with
export HOMEBREW_CASK_OPTS="--appdir=~/Applications"in your shell profile.
./Scripts/build-app.sh # builds fob.app → ~/Applications and symlinks the CLIAd-hoc-signed by default (fine for local use). Set FOB_SIGN_IDENTITY for a real signature; see docs/RELEASING.md for notarized / Homebrew builds. CLI only: swift build -c release.
One command onboards a host end to end — creates the key, installs it with ssh-copy-id, adds a ~/.ssh/config entry, verifies with Touch ID, and pins the key:
fob setup myserver you@host # or just `fob setup` and answer the promptsThen ssh myserver prompts for Touch ID and connects. Prefer to run each step yourself? fob setup --manual prints the commands and changes nothing.
Same command — git hosts have no shell, so you add the key on the web instead of via ssh-copy-id:
fob setup github git@github.comIt creates the key, writes the ~/.ssh/config entry, and prints the public key plus the link to add it — paste it on GitHub as an Authentication Key. Then:
ssh -T github # Touch ID → "Hi <you>! You've successfully authenticated"Point a repo at it (git remote set-url origin git@github:you/repo.git) and every git push takes one touch. For Verified commits, add the same key again as a Signing Key and run fob sign-setup github.
Manual setup (without the setup helper)
fob generate mykey # 1. create a Secure Enclave key (P-256)
# 2. add the printed public key to the server's authorized_keys (or GitHub)
# 3. open fob.app and enable "Launch at login"
# 4. point ssh at the agent — in ~/.ssh/config:
# Host *
# IdentityAgent ~/.fob/agent.sock
fob test-sign mykey # 5. verify the Touch ID flow, no server neededfob can't import an existing key — the Secure Enclave only generates keys on-device (P-256). That's the safety feature, not a limitation: migration means fob adds a new key alongside your old one, proves it works, and lets you retire the old one when you choose. Your current key keeps working the whole time — no cutover, no lockout.
For a host already in your ~/.ssh/config, one command does it — it installs the fob key using your current key (so it's passwordless for hosts you can already reach), backs up and rewrites the config block (showing a diff first), verifies over Touch ID, and pins:
fob adopt myserver # preview only: fob adopt myserver --dry-runThe old IdentityFile stays active as a fallback. Once you've confirmed fob works:
fob adopt myserver --retire # comment the old key out of ~/.ssh/config
# then remove the old public key from the server's ~/.ssh/authorized_keysFrom the menu-bar app: open the panel → Migrate…. It lists the hosts in your ~/.ssh/config and walks each one through install → config diff → Verify (Touch ID) → pin → optional retire, with a timestamped ~/.ssh/config backup at every write.
Git hosts have no shell, so instead of ssh-copy-id you add the key on the web. fob handles this: Migrate… lists your github-*/gitlab-* blocks (badged by provider), and Set up a host… has a Git host option for a brand-new one. The flow:
- Create key & open — copies the pubkey and deep-links to the SSH-keys page; add it as an Authentication Key.
- Config is rewritten to route the alias through fob (alongside your old key).
- Verify runs
ssh -Tand confirms "Authenticated as " over Touch ID.
fob adopt <alias> does the same from the CLI (prints the deep-link + key to add).
Signing is separate. On GitHub a signing key is a distinct entry — add the same fob key again as a Signing Key (GitLab lets one key do both). Use Sign commits with this key → in the migrate flow, or a key's ••• → Use for commit signing… (see below).
The Touch ID prompt tells you where you're connecting, not just that something wants a signature:
connect to marvin (192.168.1.20) — requested by ssh (pid 1234) with key "marvin"
Modern ssh clients (OpenSSH 8.9+) send a session-bind@openssh.com message carrying the server's host key and a signature over the session ID. fob verifies that signature (Ed25519 / ECDSA / RSA) and resolves the host to a name from known_hosts and your ssh config — so a local process can't claim a destination it didn't actually connect to. A client that omits the binding shows as "an UNKNOWN destination", so silence is visible too.
fob pin <key> <host> (done automatically by setup) refuses a key — before any Touch ID prompt — for any other destination, unverified bindings, or clients that don't identify a host. A stolen agent socket therefore can't redirect a pinned key elsewhere. unpin reverses it; policy lists every key.
Trade-off: pinned keys require OpenSSH ≥ 8.9 (older clients never send the binding).
fob reuse <key> 30 lets one approval cover the next 30 s (max 300) — enough for a git pull's many connections or an rsync burst. Reused signatures are still pin-checked, notified, and audited (signed-reused). fob reuse <key> off restores touch-per-signature.
Every decision (signed, denied, refused-pin, …) is appended to ~/.fob/audit.log as a SHA-256 hash chain — editing or deleting any line breaks it. fob audit shows recent entries; fob audit --verify checks the chain. (Tamper-evident, not tamper-proof — see Security model.)
fob.app (no Dock icon) hosts the agent in-process. Its panel shows status and a live activity feed, manages keys (generate / reuse / pin / delete), toggles Launch-at-login, and reveals the audit log. Only one agent runs at a time (an exclusive lock on ~/.fob/agent.lock); the CLI and app share ~/.fob, so fob pin / reuse / generate from the terminal take effect immediately.
A native notification on every event, so key use is never silent:
| 🔑 | a signature was issued — with the requesting process and destination |
| 🚫 | a request was denied (Touch ID cancelled or failed) |
| ⛔️ | a pinned key was blocked for the wrong destination |
| something asked for a key this agent doesn't hold |
Process attribution is best-effort (via the socket peer's PID) — treat it as awareness, not proof.
A fob key can also sign git commits — each git commit prompts Touch ID, and hosts
that verify SSH signatures (GitHub, GitLab, Gitea/Forgejo, Codeberg, …) show the commit
as Verified. It's standard SSH commit signing (ssh-keygen -Y sign), which fob's agent
serves from the Secure Enclave — so it also verifies locally, host-independently, via an
allowed_signers file. The same key can be both an Authentication key and a Signing
key on your host (they're separate entries).
fob sign-setup <key> # prints the exact steps below for a key you've generatedIt walks you through two things:
- Configure git (
gpg.format ssh,user.signingkey <the fob pubkey>,gpg.ssh.program <fob wrapper>,commit.gpgsign true). fob points git's signer at its agent through a tinygpg.ssh.programwrapper (~/.fob/bin/fob-sign) rather thanSSH_AUTH_SOCK— so only git signing reaches fob, and any other ssh agent you run (plusgit pushauth) is left untouched. Use--globalfor every repo, or--local(run inside a repo) if you switch between multiple git identities. - Register the public key on your git host as a signing key — e.g. GitHub or GitLab → Settings → SSH keys, added as a Signing Key (separate from an Authentication key).
fob tells a signing request apart from an SSH login by the SSHSIG envelope's namespace
(git for commits), shows a signing-specific prompt, and audits it as signed-git. You can
restrict which namespaces a key may sign — the signing-side analog of pinning:
fob namespaces <key> git # this key may only sign git commits
fob namespaces <key> none # disable signing entirely
fob namespaces <key> any # default — any namespaceA touch per commit adds up, so pair it with fob reuse <key> <seconds> for rebases/bursts.
A read-only pass over your SSH setup that reports hygiene problems and nudges toward fob — it changes nothing, only shows findings (with a copy-paste fix for each):
fob checkup # or the menu-bar panel → "SSH checkup"It flags:
- Unencrypted private keys on disk — with different advice for a key that's still referenced (add a passphrase, or move it to fob) versus one nothing uses anymore (delete it).
- Weak or world-readable keys — DSA / short RSA, or key files other accounts can read.
- Risky
~/.ssh/configdirectives —StrictHostKeyChecking no,UserKnownHostsFile /dev/null,ForwardAgent yes,IdentitiesOnly no(amplified when set underHost *). - Multi-account git footguns — with per-directory
includeIfidentities but nouser.useConfigOnlyguard, a repo outside those directories silently commits as your default account (the classic "wrong email leaked into a commit" bug). The robust fix is written up indocs/MULTI-ACCOUNT-GIT.md. - Signatures you can't verify locally — a fob signing key that isn't yet in
~/.ssh/allowed_signers, sogit verify-commitcan't check your own commits. - Keys loaded in your ssh-agent — on-disk keys the running agent has loaded sign with no Touch ID prompt while they're loaded (fob's own keys are excluded).
- Opportunities — plain-key hosts and non-fob signing keys you could move to fob.
A fob key is device-bound: it lives in that Mac's Secure Enclave and can't be exported, copied, or backed up. That's the whole security property — but it also means a fob key is not recoverable if the Mac is lost, wiped, or dies.
That's a lockout risk, not a compromise risk: the on-disk blob is useless on any other machine and every use needs your presence, so a stolen Mac doesn't hand anyone your keys.
Plan for it the way you would with a hardware token — keep a second key registered on anything you'd hate to be locked out of:
- another Mac running fob,
- a passphrase-protected key kept somewhere safe (password manager, offline storage), or
- a hardware security key (
ssh-keygen -t ed25519-sk).
Servers take multiple entries in ~/.ssh/authorized_keys, and GitHub/GitLab take multiple keys
per account. Add the backup once and losing a Mac is an inconvenience, not a lockout.
If you do lose a Mac, remove its public key from every host and account that trusted it
(~/.ssh/authorized_keys, your git host's SSH-keys page). Nobody else can use that key, but
pruning it keeps your authorized lists honest.
Replacing a key on purpose — rotate without downtime:
fob rotate <key> # mint a replacement alongside the current key
fob rotate <key> --finalize # swap it in and retire the old oneThe key keeps its name, so ~/.ssh/config and your git config need no changes. Register the new
public key on the host between the two steps (the app's Rotate page walks through it).
fob is a presence-gated key store, not a sandbox around your own logged-in session.
✅ Strongly protected
- Key theft — the private key never leaves the enclave; the on-disk blob is device-bound and useless elsewhere (disk access, backups,
sudoget nothing usable). - Memory scraping — the enclave performs the signature; the key never enters process memory.
- Silent use — every fresh signature requires user presence.
- Wrong destination — a pinned key signs only for its verified, bound host.
- Code running as you, while your Mac is unlocked — inherent to any ssh-agent: it can drive the agent socket (each sign still hits Touch ID, unless a reuse window is open) and read/rewrite
~/.fob. Other users are kept out (0700/0600); the line fob can't cross is your own uid. - A compromised remote host / agent-forwarding hijack — prefer
ProxyJumpoverForwardAgent. - You, the owner — the audit log is tamper-evident, not tamper-proof.
Deeper nuances — session-bind replay, lock-screen previews, and file-vs-Keychain storage
session-bindproves participation, not intent for a specific signature. The binding proves a host holding that host key took part in some key exchange; fob does not tie it to the exact payload being signed (neither does OpenSSH's own agent). A local attacker could replay a captured binding for a pinned host to satisfy the pin check — but the resulting signature is only useful against the real host within a live session whose ID was mixed into the signed data. Pinning raises the bar substantially; it is not absolute against a determined same-user attacker.- Lock-screen previews. Notifications name the destination and key; macOS may show that on the lock screen depending on System Settings → Notifications → fob → Show previews. Set it to "when unlocked" (or never) if which hosts you reach is sensitive.
- Where keys are stored. fob keeps each key as its Secure Enclave
dataRepresentation— an enclave-wrapped, device-bound blob — in a0600file under~/.fob/keys/(the age-plugin-se pattern), rather than in the macOS Keychain. This does not weaken the core protection: the private key never leaves the enclave in either model, the blob is useless on any other device, and every use is gated by the key's own presence access control no matter where the blob sits — reading the file cannot produce a signature without the prompt. The one difference from Keychain storage is that a process running as you can read the blob file; but it still can't sign without Touch ID, and same-user code can already reach the agent socket regardless. At-rest encryption is FileVault's job;0700/0600keep other users out. (Moving the blob into a code-identity-gated Keychain item is possible but low-value, since the blob isn't extractable — seedocs/CONFIG-INTEGRITY.md.)
Two independent audits of this codebase (findings resolved) plus a regression test suite (swift test) are in SECURITY_AUDIT_REPORT.md.
Out of scope (by design). fob protects SSH auth and git commit-signing keys; it deliberately does not manage macOS code-signing (Developer ID) keys — the Secure Enclave is P-256-only and Apple requires RSA-2048, so an enclave-backed signing identity isn't possible. Scope decisions and their reasoning are logged in docs/DESIGN-DECISIONS.md.
| Command | Description |
|---|---|
fob setup [alias] [user@host] |
Guided end-to-end host onboarding |
fob adopt <alias> [--dry-run] [--retire] |
Migrate an existing ~/.ssh/config host to fob |
fob generate <name> [--require-biometry] |
Create a Secure Enclave key |
fob list |
Print public keys (authorized_keys format) |
fob delete <key> [--force] |
Permanently erase a key from the enclave |
fob rotate <key> · fob rotate <key> --finalize |
Replace a key with a fresh one, keeping its name |
fob pin <key> <host> · fob unpin <key> |
Restrict a key to a host / remove all pins |
fob reuse <key> <seconds|off> |
Set the touch-reuse window (max 300 s) |
fob policy |
Show every key's pin + reuse state |
fob audit [--verify] |
Show recent decisions / verify the hash chain |
fob checkup |
Read-only ~/.ssh hygiene report (keys, config, identity/signing) |
fob test-sign <key> |
Exercise the Touch ID flow, no server needed |
Most actions are also available from the menu-bar panel.
Notes: keys are
ecdsa-sha2-nistp256(the enclave's only curve); no import/export by design.--require-biometrybinds a key to the currently enrolled fingerprints (re-enrolling invalidates it); the default also accepts Apple Watch / password. Deleting a key is permanent — nothing can recreate it.
We first came across the idea of keeping SSH keys in the Secure Enclave through Secretive by Max Goedjen (see its README). We liked the concept — a menu-bar agent that never lets the key leave the enclave — and it's worth a look if fob isn't what you're after.
We built fob rather than adopting it to center a few different ideas:
- Destination-aware authorization — the verified destination in the Touch ID prompt, not just that something wants a signature.
- Per-host pinning — a key refused, before any prompt, for every host but the one it's bound to.
- Opt-in touch reuse, a tamper-evident audit log, and a CLI-first guided
fob setup. - A tiny, zero-dependency codebase you can read and audit in an afternoon.
Copyright © 2026 Olivier Devaux.
fob is free software under the GNU Affero General Public License v3.0 (AGPL-3.0) — use, study, modify, and redistribute it freely, but any distributed or network-served derivative must also be open-sourced under the same license.