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.
Passkey registration
Section titled “Passkey registration”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.
Cross-device passkey invites
Section titled “Cross-device passkey invites”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:
- 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 inpending_confirmationstate and is unusable until the confirm step (which is gated), so issuing one with just aui-scoped token doesn’t change a login factor. - 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). - 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 inpending_confirmationstate. - Back on the original device, you confirm the freshly-registered credential at
/api/webauthn/credentials/:id/confirm— this step is behind a step-up assertion. The new passkey only becomes a usable login factor after that confirmation flips it frompending_confirmationtoactive.
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.
Sessions and idle cleanup
Section titled “Sessions and idle cleanup”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.
OAuth tokens vs passkeys
Section titled “OAuth tokens vs passkeys”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.
| Passkey | OAuth token | |
|---|---|---|
| Used by | Humans (login, sign approvals) | Agents (MCP, agent proxy) and the web UI itself |
| Lives where | Your authenticator (ShellWatch stores the COSE public key) | Issued by Hydra; opaque; verified by introspection |
| Issued where | Settings → Passkeys (/settings/keys) | The OAuth flow — browser passkey login + consent |
| Lifetime | Until revoked | Access token 30 min; refresh token 30 days, rotating |
| Scope | Tied to an account | Carries the account identity (sub) plus a scope (ui, mcp, or agent) |
| Authorises | Browser session, sign requests | Routing — the actual privileged operation still needs a passkey approval |
Token scopes
Section titled “Token scopes”| Scope | What it unlocks |
|---|---|
ui | /api/* and /ws — reserved for the first-party web UI, never grantable to third-party clients |
mcp | POST /mcp (MCP streamable HTTP) |
agent | WS /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
Section titled “Revoking a passkey”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.)
Where to next
Section titled “Where to next”- Concepts → Sign requests — what happens when a key is used.
- Concepts → Security model — why this whole arrangement is sound.