SSH keys in the Mac's Secure Enclave, so your AI agents can't take them

This week I checked the security of my Mac. Nothing was infected, but I found something I liked less: eight SSH keys in ~/.ssh, none of them with a passphrase. Any process that runs as my user can read them, and today that includes several AI agents with terminal access. I replaced them with a key that lives inside the Mac's Secure Enclave, can't be copied, and asks for Touch ID on every new connection. macOS already ships everything you need. You don't have to install anything.

The technique is not mine: Arian van Putten documented it in a gist. What I describe here is how I used it in a real setup, with several servers, GitHub and an always-on tunnel, and the problems I hit on the way.

The problem: your agents have your keys

An agent like Claude Code, Codex or any other tool that runs commands works as your user. If it can run cat, it can read ~/.ssh/id_ed25519. Lists of blocked commands don't help much, because there are many ways to read a file without typing cat. And the agent doesn't need to make a mistake on its own. It only needs to read a web page or an email with hidden instructions (prompt injection) and it may try to send your key somewhere else.

A passphrase protects the file, but once the key is loaded in the SSH agent, any of your processes can use it without asking you. I wanted two things: a key that can never be copied, and a check on every use that only I can pass.

What macOS ships

macOS includes a "security key" provider for OpenSSH in /usr/lib/ssh-keychain.dylib. It is the same interface a YubiKey uses, but here the key is the Mac's own Secure Enclave. According to the gist it works on macOS 26 Tahoe, and some comments say Sequoia too. I tested it on macOS 27.0.1 with the system OpenSSH (10.3). Apple does not document this for SSH anywhere: the ssh-keychain man page is from 2020 and only talks about smartcards.

The private key is created inside the chip and, if you pick the non-exportable type, it never leaves it. On disk you only get a reference file that is useless on another computer.

Step by step

1. Check that your Mac supports it

ls -la /usr/lib/ssh-keychain.dylib
nm -gU /usr/lib/ssh-keychain.dylib | grep sk_
ssh -V

If you see sk_enroll and sk_sign, the provider is there.

2. Create the key in the Secure Enclave

sc_auth create-ctk-identity -l ssh-mac -k p-256-ne -t bio
sc_auth list-ctk-identities
  • -k p-256-ne: non-exportable P-256. Without -ne the key can be exported with sc_auth export-ctk-identity, and that defeats the point. There is a p-384-ne type, but OpenSSH doesn't accept it.
  • -t bio: Touch ID on every signature. With -t none there is no fingerprint check; I use that for GitHub below.

3. Get the public key and the reference file

mkdir -p ~/.ssh/secure-enclave && cd ~/.ssh/secure-enclave
ssh-keygen -K -N "" -w /usr/lib/ssh-keychain.dylib

It asks for your fingerprint and then for a PIN: leave it empty. You get id_ecdsa_sk_rk_ssh-mac and its .pub. The first file does not contain the private key, it only points to it, so -N "" is not a problem.

One detail cost me some time: -K downloads every key in the chip, not only the new one. If the folder already has the file of an older key, it asks whether to overwrite it, and if you run it from a script it just stops there. The easy fix is to run it in an empty folder and then move the file you want.

4. Configure SSH

# ~/.ssh/config
Host my-server
    HostName server.example.com
    User deploy
    IdentityFile ~/.ssh/secure-enclave/id_ecdsa_sk_rk_ssh-mac
    SecurityKeyProvider /usr/lib/ssh-keychain.dylib
    IdentitiesOnly yes
    ControlMaster auto
    ControlPath ~/.ssh/cm/%r@%h:%p
    ControlPersist 1h

IdentitiesOnly stops SSH from trying your old keys first. The ControlPath folder must exist (mkdir -p ~/.ssh/cm). I explain ControlMaster further down.

5. Authorize the key on the server and test it

Add the content of the .pub file to ~/.ssh/authorized_keys on the server, without removing the old key yet, and connect:

ssh my-server 'echo ok'

Touch ID pops up and you are in. To be sure it used the key in the chip and not another one, check the server log. It should say ECDSA-SK:

journalctl -u ssh --since "-5 min" | grep Accepted
# Accepted publickey for deploy from ... ssh2: ECDSA-SK SHA256:...

What the original guide doesn't cover

The server has to support it

This kind of key needs OpenSSH 8.2 or newer on the server. One of mine runs AlmaLinux 8, which ships OpenSSH 8.0, and it rejects the key with nothing more than "Permission denied". You can check it like this:

sudo sshd -T | grep -i pubkeyacceptedalgorithms | tr ',' '\n' | grep sk-

If the output is empty, that server keeps a normal key, with a passphrase, until you upgrade it.

One fingerprint per connection, not per command

My first thought was: "Will I have to touch the sensor for every command?" No. Touch ID is asked for each new connection. With ControlMaster and ControlPersist, the first connection stays open and the next ones reuse it without asking. I use one hour: one fingerprint per server per hour of work.

There is a cost. While that connection is open, another process running as your user can use it without a fingerprint too. A longer window is more comfortable and less safe. One hour feels like a fair balance to me; days do not.

GitHub: a second key without a fingerprint

For GitHub I did something different. My gh CLI is logged in with a token that can push over HTTPS, and any agent could use it without a fingerprint. Adding Touch ID only to the SSH path protected almost nothing and got in the way on every push. So I created a second key in the chip, with no fingerprint check, that still can't be copied:

sc_auth create-ctk-identity -l ssh-github -k p-256-ne -t none

If your Mac has no token with write access, the fingerprint on GitHub does make sense.

Automation needs its own key

I have an SSH tunnel that launchd keeps open all day. Touch ID can't work there, because nobody is around to touch the sensor. The fix is a normal key without a passphrase, for a user that is not root, and limited on the server to that one job. In its authorized_keys:

restrict,port-forwarding,permitopen="127.0.0.1:8080",command="/usr/sbin/nologin" ssh-ed25519 AAAA... mac-tunnel

restrict turns everything off, and then only forwarding to that port is allowed again. If someone steals that key, they can't open a shell or reach another port. I tested it before trusting it: a login returns "This account is currently not available" and forwarding to another port fails with "administratively prohibited".

No attestation and no backup

Two limits you should know about. Apple gives no attestation for these keys, so you can't prove to a third party that the key really lives in hardware; the gist mentions this too. And since the key can't be exported, if you lose or wipe the Mac, you lose the key. Before you remove the old keys from a server, make sure you have another way in: the provider's web console, a panel like Plesk, or a second key stored somewhere else.

How I did the migration

  • I created the new key and added it to the servers by logging in with the old keys, without removing anything.
  • I tested each server with the new key and checked in the log that it came in as ECDSA-SK.
  • I removed only this Mac's keys from authorized_keys and from GitHub, matching the public key and not the comment. Keys from other machines and deploy keys stayed.
  • I checked the opposite: the old keys no longer get in.
  • I didn't delete the keys I couldn't remember the use of. I moved them to ~/.ssh/retired/. SSH no longer tries them by default, and if something breaks in a few weeks I'll know what it was.

What I wouldn't do

  • Use the exportable type "just in case".
  • Set ControlPersist to days.
  • Remove the old keys before testing the new one and having another way into the server.
  • Give the tunnel key a shell, or put it on root.

Is it worth it?

For me, yes. The change in habits is small, one fingerprint per server per hour, and it removes a risk that AI agents have made very real: something running as your user walking away with the keys to your servers. A compromised agent can still try to use the key, but it can't copy it. And if it tries, you get a Touch ID prompt you didn't expect. That surprise prompt is exactly the alarm you want.

Sources