Skip to content

Security best practices

ShellWatch verifies WebAuthn assertions end-to-end — the broker only relays the signature, and the destination (sshd, or PAM on the target) verifies it. The protocol-level guarantee that the credential is never transmitted holds for every class of WebAuthn authenticator. But the strength of “your credential stays on your authenticator” depends on which class of authenticator you registered, and ShellWatch deliberately doesn’t restrict that choice.

This page covers the choices you make outside ShellWatch that change the actual security posture of a deployment.

ClassExamplesWhere the private key livesWhat “compromise” looks like
Hardware-bound (FIDO2 token)YubiKey, SoloKey, TitanOn the token only; not extractable.Physical theft of the token plus the user’s PIN or biometric. Cloning is not feasible.
Platform-bound (device-only)Touch ID with iCloud Keychain sync disabled; Windows Hello with TPM-bound keys; Android with sync offOn the device’s secure enclave / TPM. Doesn’t leave that device.Compromise of the specific device plus its biometric or local PIN.
Synced platform passkeyiCloud Keychain (sync on), Google Password Manager, 1Password, Bitwarden, DashlaneOn the device, and in the vendor’s encrypted cloud sync.Whatever it takes to take over the vendor account (Apple ID, Google account, password-manager master password, etc.).

All three produce valid WebAuthn signatures; all three work for ShellWatch login and SSH signing. They differ in what an attacker has to compromise to obtain a valid assertion. Match the class to the value of what you’re protecting.

WebAuthn doesn’t expose authenticator class directly to the relying party in a forgery-proof way (attestation is opt-in and not enforced by ShellWatch), so this is a discipline you impose on yourself when registering credentials, not something the server polices.

ShellWatch endpoints aren’t bound to a specific registered passkey. When you open a session, ssh2 presents all of the account’s available credentials and sshd accepts whichever public key matches a line in ~/.ssh/authorized_keys on the target — standard OpenSSH key negotiation, not a ShellWatch-specific binding. The practical consequence is that the security floor for any given target is set by the weakest credential whose pubkey appears in that target’s authorized_keysnot by everything registered on the account.

This makes the actionable rule per-target, not per-account:

  • Match credential class to target sensitivity in authorized_keys. On a production / privileged host, list only the pubkeys of hardware-bound passkeys. On lower-stakes hosts (staging, dev, personal) a synced platform passkey’s pubkey is fine. The per-target file is the gate.
  • Mixing credential classes on one account is fine — registering a synced passkey alongside a hardware-bound one doesn’t lower the floor on any target where you didn’t authorize the synced one’s pubkey. Compromise of credential B is only useful against targets whose authorized_keys list B’s pubkey.
  • Per-endpoint UV requirement. The WebAuthn userVerification setting on the endpoint (default required), combined with PubkeyAuthOptions verify-required on sshd, makes the target reject any signature that doesn’t carry the UV bit — see Server-side enforcement. This applies regardless of credential class and is a per-target / per-key knob you control via sshd_config and authorized_keys.
  • Audit visibility. The audit log records which registered passkey signed each approval, so the credential class actually used is visible after the fact.

If you want to be strict about a high-sensitivity target, the discipline is in authorized_keys on that target — list the .pub of hardware-bound passkeys only. Use the cross-device invite flow (or a second hardware key) as the backup pattern for those accounts — and don’t paste a synced credential’s pubkey into a production target’s authorized_keys “just in case”.

Even a hardware-bound passkey only gets you the strength your destination requires. Two things to set on the target host:

  • sshd_config: PubkeyAuthOptions verify-requiredsshd rejects any signature that doesn’t carry the User Verification flag bit, regardless of what ShellWatch claims. Without this, a hypothetical broker that produced UV=0 signatures could authenticate. With it, UV is load-bearing on the target. See Security model → Server-side enforcement.
  • PAM stack with pam-ssh-agent-webauthn for sudo and other PAM-aware steps. The verifier independently checks the signature against /etc/security/authorized_keys on the target; a compromised broker can deny or delay, but cannot manufacture a sudo approval.

Containment after login is the host’s problem

Section titled “Containment after login is the host’s problem”

ShellWatch’s job ends when the SSH handshake completes. Once a session is open, anything the shell can read or write is in scope for whatever’s holding the shell — including an AI agent. A passkey-signed handshake doesn’t, on its own, prevent:

  • An agent reading ~/.ssh/, cloud credential files, or other secrets that happen to be on disk.
  • An agent invoking sudo if the user is in sudoers without a PAM passkey check.
  • An agent exfiltrating output, modifying files within its permissions, or pivoting via stored credentials it discovers.
  • Long-running background processes the agent backgrounds and that outlive the visible session.

These are host-side containment problems, not authentication problems, and ShellWatch deliberately doesn’t try to solve them. The patterns that do help — they all live on the target host:

  • Scoped Unix accounts. Give the AI-agent path its own user with the minimum filesystem access it actually needs. Don’t run it under a user that can read other users’ home directories.
  • pam-ssh-agent-webauthn on sudo. Gate every privilege escalation on a fresh passkey approval. Without it, an agent that lands on a sudoer account can elevate silently. See Guides → PAM module.
  • Restricted shells / ForceCommand. Use ~/.ssh/authorized_keys command="..." or ForceCommand in sshd_config to pin what an agent’s session can run when the use case is narrow (e.g. “this agent only ever runs git fetch”).
  • MAC frameworks (AppArmor, SELinux). Constrain what a process can read and execute, regardless of Unix permissions.
  • Don’t leave credentials on the box. If the agent can’t read a long-lived cloud-credential file, it can’t pivot to your cloud. Inject short-lived tokens at session start, not files at provisioning time.
  • Audit at the host level. ShellWatch’s audit log records that a sign happened and the context the approver saw; auditd / shell history / process auditing on the target records what happened after the connection opened.

The rule of thumb: ShellWatch gives you a per-connection human gate. Defense in depth on the target host is what limits the blast radius of a connection that you (or a teammate) approved, after the fact.

  • Hardware-bound credential registered for production endpoints; not relying on a synced passkey there.
  • At least two passkeys registered as backup — see Cross-device passkey invites.
  • PubkeyAuthOptions verify-required set in sshd_config on every target.
  • pam-ssh-agent-webauthn configured on /etc/pam.d/sudo for hosts where AI agents may operate.
  • Per-purpose Unix accounts; AI-agent paths run under unprivileged users.
  • No long-lived cloud-credential files on hosts an AI agent will reach.
  • Host-level auditing (auditd, shell history) configured on hosts that handle real work.