Skip to content

Accounts & passkeys

ShellWatch is passkey-first. There are no passwords anywhere in the system — not for human login, not for SSH (authentication is by WebAuthn passkey), not for unlock.

Each account is its own silo: sessions, output buffers, endpoints, and passkeys are scoped to the calling account on every interface (Web UI, MCP, agent proxy). Visibility never crosses accounts.

Settings → Passkeys (/settings/keys) lists your registered credentials. Adding one walks you through a standard WebAuthn registration ceremony bound to shellwatch.ai.

Each credential is stored with:

  • COSE public key (for verifying assertions on login)
  • OpenSSH-format public key (for use as an SSH key — type webauthn-sk-ecdsa-sha2-nistp256@openssh.com)
  • Label (your own name for the credential — “Yubikey 5C”, “MacBook Touch ID”, etc.)
  • Counter (advanced — currently not enforced for PAM, see the PAM module’s known limitations)

Register more than one. A YubiKey on your keychain plus a platform authenticator on each device gives you a workable backup story. A passkey is non-portable across relying parties — credentials registered on app.shellwatch.ai won’t work against a self-hosted instance, and vice versa.

The platform authenticator on a phone or a second laptop can’t be registered remotely — WebAuthn requires the new device to interact with the relying party directly. To bridge that gap, ShellWatch issues a short-lived passkey invite token from your already-logged-in browser:

  1. On the device that’s already logged in, Settings → Passkeys → Invite a device issues an invite via POST /api/webauthn/invite. The invite endpoint itself is not step-up gated — the resulting credential lands in pending_confirmation state and is unusable until the confirm step (which is gated), so issuing one with just a ui-scoped token doesn’t change a login factor.
  2. The server returns a one-time URL of the form /passkey-invite/<token>. You hand that URL to the new device (QR code, AirDrop, copy/paste).
  3. On the new device, opening the URL drops you into a token-gated registration page (/api/passkey-invite/register/options/api/passkey-invite/register) that runs a normal WebAuthn registration ceremony — but bound to your existing account, not creating a new one. The credential is inserted in pending_confirmation state.
  4. Back on the original device, you confirm the freshly-registered credential at /api/webauthn/credentials/:id/confirmthis step is behind a step-up assertion. The new passkey only becomes a usable login factor after that confirmation flips it from pending_confirmation to active.

Tokens are single-use, short-lived, and supersede each other (issuing a new invite invalidates the previous one). The two-step “register on B, confirm on A” pattern means that even an attacker who steals an in-flight invite URL still can’t turn the resulting credential into a usable login factor — confirmation requires a fresh assertion from one of the existing active credentials on the account. The full set of passkey-management operations gated this way (and the ones deliberately not gated) is documented under Security model → Step-up auth.

Login is an OAuth 2.1 flow against Ory Hydra, gated by your passkey. There’s no separate ShellWatch login screen any more — Hydra owns the login UI: when the web UI has no token it starts the authorization flow, Hydra redirects to ShellWatch’s server-rendered passkey login page, the WebAuthn ceremony resolves to one of your registered credentials, and the SPA ends up holding an access token (in memory) plus a rotating refresh token. The same flow is what an MCP client or shellwatch-agent login triggers in your browser.

For a third-party client (MCP, agent), the flow adds a consent step after login. If you’ve just logged in with your passkey, approving that client is a single button tap — no second passkey prompt. If you already had a remembered Hydra login session, the consent page asks for one fresh passkey tap as a presence check. The first-party web UI is auto-approved, so signing into the UI is a single passkey tap end to end.

Deep links survive the round-trip — opening /sign/:id from a Web Push notification when your session has expired bounces you through the passkey login flow first, then returns you to the approval page.

The Web UI’s notion of a “session” (OAuth tokens — a 30-minute access token silently renewed from a 30-day rotating refresh token) is separate from a terminal session (running SSH connection). Terminal sessions stay open until explicitly closed, the SSH connection drops, or the idle timeout kicks in. Inactive accounts are cleaned up after 90 days.

Settings → Sessions lists every OAuth client you’ve authorized — the web UI itself (badged “This app”), each MCP client, each shellwatch-agent enrolment — with its granted scopes and when it was last authorized. From there you can invalidate one client (signs just that client out) or invalidate all sessions (kills every grant and login session, signing you out everywhere, including the current browser). Both actions require a fresh passkey step-up. Logging out normally revokes the current grant at Hydra via RP-initiated logout.

Agents (MCP clients, shellwatch-agent) authenticate with OAuth access tokens; humans authenticate with passkeys. The token is minted by a passkey ceremony — every client enrols through the same browser login + consent flow.

PasskeyOAuth token
Used byHumans (login, sign approvals)Agents (MCP, agent proxy) and the web UI itself
Lives whereYour authenticator (ShellWatch stores the COSE public key)Issued by Hydra; opaque; verified by introspection
Issued whereSettings → Passkeys (/settings/keys)The OAuth flow — browser passkey login + consent
LifetimeUntil revokedAccess token 30 min; refresh token 30 days, rotating
ScopeTied to an accountCarries the account identity (sub) plus a scope (ui, mcp, or agent)
AuthorisesBrowser session, sign requestsRouting — the actual privileged operation still needs a passkey approval
ScopeWhat it unlocks
ui/api/* and /ws — reserved for the first-party web UI, never grantable to third-party clients
mcpPOST /mcp (MCP streamable HTTP)
agentWS /agent-proxy (SSH agent proxy)

offline_access is granted alongside, so clients can refresh silently. An OAuth client is never bound to an account — the token carries the identity of whoever logged in, so revoking access means revoking the grant (log out, or use Settings → Sessions), not deleting a key.

Revoking a passkey in Settings → Passkeys removes it as a login factor (step-up gated — you confirm with another active passkey). The revoke dialog also offers an “also invalidate all active sessions” checkbox: tick it to additionally kill every OAuth grant and login session for the account, forcing every device and client to log in again. Leave it off for routine cleanup; tick it when the passkey was lost or compromised. (It’s off by default.)