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.
The guarantee
Section titled “The guarantee”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 — bysshdfor SSH, or bypam-ssh-agent-webauthnfor 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.aiis 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
sshditself — the same OpenSSH binary that has been gating SSH everywhere for two decades. ShellWatch handssshdawebauthn-sk-ecdsa-sha2-nistp256@openssh.comsignature;sshdchecks it against the registered public key in~/.ssh/authorized_keysand rejects anything that doesn’t verify. ShellWatch does not ship or modifysshd. - PAM steps (
sudo, etc.) are verified bypam-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).
What is actually trusted
Section titled “What is actually trusted”| Layer | Trusted? | Why |
|---|---|---|
| The WebAuthn authenticator (YubiKey, Touch ID, Windows Hello, synced platform passkey, …) | Yes — at the protocol level | Issues 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 check | Yes | WebAuthn binds the assertion to the relying-party ID. |
sshd on the target | Yes | Independently 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 host | Yes | Independently checks the ECDSA signature against /etc/security/authorized_keys. |
| The ShellWatch broker | Transport, not trust | Can deny or delay signatures, and can show live session output to attached viewers, but cannot forge a signature. |
| The OAuth access token over the wire | Routing, not authorisation | Identifies which account is asking. The actual signing is end-to-end. |
X-ShellWatch-Hostname / -OS / -Version from the agent client | Self-reported | Surfaced on /sign/:id in a separate “self-reported” block — operators should treat as a hint, not as identity. |
Server-side enforcement (load-bearing UV)
Section titled “Server-side enforcement (load-bearing UV)”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:
PubkeyAuthOptions verify-requiredor per-key in ~/.ssh/authorized_keys:
verify-required sk-ecdsa-sha2-nistp256@openssh.com AAAA... user@hostsshd 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.
Two deployment models, one signing flow
Section titled “Two deployment models, one signing flow”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.
| Property | Broker-mediated session (Web UI / MCP) | Direct SSH via agent proxy |
|---|---|---|
| Who holds the SSH connection? | ShellWatch ssh2 client | Your local ssh |
| What does the broker see? | The full session — input, output | Only 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 trail | Session open/close + signing-request outcomes (no input/output bytes) | Signing-request outcomes only |
| Trust assumption on the broker | Trusted intermediary — it can deny, delay, or buffer | Trustless — even a fully compromised broker cannot read or alter the session it just signed for |
| Who runs the SSH client? | The broker, on your behalf | A process on your host (ssh, git, ansible, an AI agent’s shell tool, …) |
| Typical use | An 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 frompending_confirmationtoactive.
The flow:
- The user (already logged in) clicks Add / Revoke / Confirm.
- The browser hits
/api/webauthn/stepup/optionsto get a fresh WebAuthn challenge. - The user completes the ceremony with one of the account’s existing active passkeys — not 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/verifymints 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 aspending_confirmationand 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.
Where the access token fits
Section titled “Where the access token fits”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.
Tamper-evident audit log
Section titled “Tamper-evident audit log”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.
What ShellWatch does not protect against
Section titled “What ShellWatch does not protect against”- A target host that trusts a non-WebAuthn key. ShellWatch can’t enforce policy on machines it doesn’t run on. Use
verify-requiredonsshdand gatesudowithpam-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
auditdif you need a written record, and host-side containment (pam-ssh-agent-webauthnonsudo, 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.aithis is our problem; on a self-hosted instance, run the broker on a host you trust. - Phishing of approval prompts. The
/sign/:idpage shows context (source IP, agent client metadata), but a careless approver can still wave through a malicious request. Train approvers to read the prompt.
Further reading
Section titled “Further reading”- Concepts → Sign requests & approvals — what the approver sees and how the flow works mechanically.
- Concepts → Accounts & passkeys — the account/credential model.
- Concepts → Security best practices — picking the right authenticator class per endpoint, and what containment falls outside ShellWatch.
- Guides → PAM module — the
pam-ssh-agent-webauthnverifier on the target host.