Skip to content

Security model

ShellWatch’s security guarantee is simple and load-bearing: every SSH connection, and every PAM-aware step (sudo, etc.) on a ShellWatch-managed target, requires a fresh end-to-end WebAuthn passkey signature — produced by a registered authenticator on a human approver’s device, verified on the target host by sshd or PAM. ShellWatch enables this flow seamlessly. It does not produce signatures, and it does not verify them.

This page explains the chain of trust, why each link is robust, and where the agent’s access token fits (spoiler: routing, not authorisation). It deliberately doesn’t take a position on which kind of WebAuthn authenticator you use — that’s a separate axis covered in Security best practices.

No SSH session opens, and no sudo (or other PAM-aware) escalation succeeds, without a valid WebAuthn signature freshly produced by a human approver and verified on the target host — by sshd for SSH, or by pam-ssh-agent-webauthn for PAM.

There is no “remember this agent for an hour” flag and no silent auto-sign on any path. Every connection, every escalation, one fresh approval ceremony.

The robustness of that guarantee rests on three properties.

1. Signature creation is battle-tested in the browser

Section titled “1. Signature creation is battle-tested in the browser”

Every signature is produced by a WebAuthn ceremony in the approver’s browser, using a credential the user previously registered. WebAuthn is implemented by the browser vendor and the OS authenticator stack (Touch ID, Windows Hello, hardware tokens, platform passkeys) — code paths that handle billions of login ceremonies a day on every major web property. ShellWatch doesn’t reimplement any of that; it presents a relying-party challenge and consumes the resulting assertion.

Two protocol-level properties matter here:

  • The credential is bound to the relying-party ID (the broker’s domain). A signature produced for app.shellwatch.ai is unusable on any other origin. Phishing the browser into producing a signature for an attacker-controlled site doesn’t work.
  • The private key never leaves the authenticator. The broker, the network, and even the user’s own browser only ever see the resulting signature — never the key.

2. Signature verification runs on the target, in code that has been verifying SSH for years

Section titled “2. Signature verification runs on the target, in code that has been verifying SSH for years”

ShellWatch never decides whether a signature is valid. The destination does:

  • SSH connections are verified by sshd itself — the same OpenSSH binary that has been gating SSH everywhere for two decades. ShellWatch hands sshd a webauthn-sk-ecdsa-sha2-nistp256@openssh.com signature; sshd checks it against the registered public key in ~/.ssh/authorized_keys and rejects anything that doesn’t verify. ShellWatch does not ship or modify sshd.
  • PAM steps (sudo, etc.) are verified by pam-ssh-agent-webauthn, a standalone PAM module maintained by the ShellWatch project and aligned with OpenSSH’s WebAuthn implementation. The verifier runs in PAM, on the target host, and independently checks the ECDSA signature against /etc/security/authorized_keys. It has no runtime dependency on the ShellWatch broker.

Both verifiers live on the target machine, not in ShellWatch. A compromised broker can refuse to relay a signature, but it cannot forge one that the target will accept.

3. ShellWatch is the conduit, not the trust root

Section titled “3. ShellWatch is the conduit, not the trust root”

ShellWatch’s job is to make the above flow seamless: surface a PendingAction to the approver, relay the resulting assertion, broker the SSH/PAM transport. It does not produce signatures, hold private keys, or verify anything that gates access to a target. The phrase “end-to-end, human-in-the-loop” is precise: end-to-end because the signature is produced at the authenticator and verified at the destination; human-in-the-loop because every signature requires a fresh user-approval gesture (touch, biometric, or PIN — depending on the authenticator class).

LayerTrusted?Why
The WebAuthn authenticator (YubiKey, Touch ID, Windows Hello, synced platform passkey, …)Yes — at the protocol levelIssues the WebAuthn assertion; the broker can’t forge one. The strength of “can’t be forged remotely” depends on the authenticator class — see Best practices.
The user’s browser origin checkYesWebAuthn binds the assertion to the relying-party ID.
sshd on the targetYesIndependently verifies the WebAuthn signature against ~/.ssh/authorized_keys. With PubkeyAuthOptions verify-required, also rejects anything without the UV flag bit.
The PAM verifier (pam-ssh-agent-webauthn) on the target hostYesIndependently checks the ECDSA signature against /etc/security/authorized_keys.
The ShellWatch brokerTransport, not trustCan deny or delay signatures, and can show live session output to attached viewers, but cannot forge a signature.
The OAuth access token over the wireRouting, not authorisationIdentifies which account is asking. The actual signing is end-to-end.
X-ShellWatch-Hostname / -OS / -Version from the agent clientSelf-reportedSurfaced on /sign/:id in a separate “self-reported” block — operators should treat as a hint, not as identity.

By default, ShellWatch performs the WebAuthn ceremony with userVerification: "required", so every signature carries the UV flag. To make that load-bearing — i.e. so the target host actually rejects signatures without UV — configure sshd:

/etc/ssh/sshd_config
PubkeyAuthOptions verify-required

or per-key in ~/.ssh/authorized_keys:

verify-required sk-ecdsa-sha2-nistp256@openssh.com AAAA... user@host

sshd then rejects any signature where SSH_SK_USER_VERIFICATION_REQD (0x04) isn’t set, regardless of what ShellWatch claims. See the SSH agent proxy guide for details.

ShellWatch can be put in front of an SSH session in two shapes. Both share the same end-to-end signing flow above; they differ in how much of the session bytes the broker sees.

PropertyBroker-mediated session (Web UI / MCP)Direct SSH via agent proxy
Who holds the SSH connection?ShellWatch ssh2 clientYour local ssh
What does the broker see?The full session — input, outputOnly the signature ceremony. Zero session bytes.
Live observation / takeover?Yes (Observer view, take-over)No
Session input/output persisted?No. A live in-memory buffer feeds attached viewers; nothing is written to disk.No (broker never sees the bytes)
Audit trailSession open/close + signing-request outcomes (no input/output bytes)Signing-request outcomes only
Trust assumption on the brokerTrusted intermediary — it can deny, delay, or bufferTrustless — even a fully compromised broker cannot read or alter the session it just signed for
Who runs the SSH client?The broker, on your behalfA process on your host (ssh, git, ansible, an AI agent’s shell tool, …)
Typical useAn MCP agent runs commands; you watch and approve. PAM / sudo / SSH hop forwarding all flow through this path.Local tooling — including a local AI coding agent — connects to targets directly with a passkey tap per connection.

The two models are not exclusive — you can use both against the same target host with the same registered passkey. They sit at two different points on the observability vs. trust-surface tradeoff: broker-mediated mode lets you watch and take over a live session, at the cost of putting ShellWatch on the wire; the agent-proxy path keeps ShellWatch off the session wire entirely, at the cost of giving up live observation. Either way the human-in-the-loop signature is mandatory and verified on the target.

Step-up auth on sensitive passkey operations

Section titled “Step-up auth on sensitive passkey operations”

Day-to-day signing happens through the /sign/:id flow. But the passkey-management surface itself is a juicier target — anything that lets an attacker durably add or remove a login factor needs more than a bearer token. ShellWatch gates exactly the three terminal mutations behind a separate step-up assertion:

  • POST /api/webauthn/register — finalising registration of a new credential.
  • POST /api/webauthn/credentials/:id/revoke — revoking an existing credential.
  • POST /api/webauthn/credentials/:id/confirm — promoting an invite-registered credential from pending_confirmation to active.

The flow:

  • The user (already logged in) clicks Add / Revoke / Confirm.
  • The browser hits /api/webauthn/stepup/options to get a fresh WebAuthn challenge.
  • The user completes the ceremony with one of the account’s existing active passkeysnot the credential being mutated. (Asserting with passkey Y is enough to revoke passkey X. That’s deliberate: revoking a lost authenticator is exactly the case where the user can’t assert with the one being revoked.)
  • /api/webauthn/stepup/verify mints a single-use, action-bound, account-bound, ~90 s token.
  • The token is presented to the gated endpoint and consumed.

A few intentional non-gates:

  • Issuing an invite (POST /api/webauthn/invite) needs only an authenticated UI session. The credential it eventually produces lands as pending_confirmation and is unusable for login until the confirm step (which is gated), so the load-bearing assertion is on the confirm, not the mint.
  • /api/webauthn/stepup/* itself is not step-up gated — that would be a chicken-and-egg.

A stolen token, a sandbox-escaped XSS, or a phished session cannot silently add, revoke, or confirm a passkey — they’d need a fresh user-approval ceremony on an authenticator the legitimate user already registered.

The OAuth access token authenticates an agent (an MCP client, a shellwatch-agent daemon) to ShellWatch. It’s issued by Ory Hydra after a passkey-gated login + consent, carries the account identity (sub) and a coarse scope (mcp or agent), and is resolved by token introspection on every request. Its job is fan-in — route the request to the right account, scope to MCP vs agent. It is not load-bearing for the privileged action.

A stolen access token can ask for a session; it cannot open one. The request triggers a PendingAction that surfaces on every signed-in browser tab (and, with Web Push, on the human’s phone), with the requester’s IP and the agent client’s self-reported hostname/OS/version. Nothing happens until the human approves on /sign/:id with their passkey.

To bypass the guarantee, an attacker would have to compromise both a valid token and a registered authenticator, and induce the human to approve on it. Tokens are also short-lived by construction: access tokens expire after 30 minutes, refresh tokens rotate, and revoking a grant kills access within the introspection-cache TTL (60 s by default) — but none of that is load-bearing on the security guarantee; the passkey gate is.

Every session open/close and every signing-request outcome is recorded append-only to SQLite (audit_session_lifecycle, audit_signing_requests) with the full SignRequestContext snapshotted at the time of the decision. The audit module never joins to live tables at read time, so a passkey rename, endpoint relabel, or token revocation does not rewrite history.

This is defense in depth, not a primary control. A compromised broker host can still tamper with the SQLite file directly. The audit log’s job is to give an honest broker a clean record of what happened — useful for incident review, compliance, and noticing things like a sign request you don’t remember approving. See Concepts → Audit log.

  • A target host that trusts a non-WebAuthn key. ShellWatch can’t enforce policy on machines it doesn’t run on. Use verify-required on sshd and gate sudo with pam-ssh-agent-webauthn.
  • Output exfiltration after authentication — once a session is open, the agent can read whatever the shell can read. ShellWatch does not persist session input or output, so post-hoc replay from the broker is not available; use the Observer view (your own sessions only) to spot misuse live, host-side recorders or auditd if you need a written record, and host-side containment (pam-ssh-agent-webauthn on sudo, restricted shells, MAC frameworks) to limit blast radius. See Security best practices → Containment after login is the host’s problem.
  • A compromised broker host. If an attacker has root on the machine running the broker, they can read the SQLite DB and reach Hydra’s admin API. On app.shellwatch.ai this is our problem; on a self-hosted instance, run the broker on a host you trust.
  • Phishing of approval prompts. The /sign/:id page shows context (source IP, agent client metadata), but a careless approver can still wave through a malicious request. Train approvers to read the prompt.