Security and help
OAuth threat model
What the OAuth server (oauth.md) protects, from whom, how, and what is left. It
covers /.well-known/*, /oauth/*, /connections*, the OAuth path of /mcp and the approval
in the Android app. API keys, pairing and app JWTs are described in
architecture.md and pairing.md.
Assets
- The owner’s plan: tasks, history and settings, read and changed through
/mcp. - Access and refresh tokens of agent connections, authorization codes, and the codes shown on the sign-in page.
- The owner’s decision: only the owner, on the paired phone, may let an agent in, and only with the scopes the owner chose.
Actors and trust boundaries
| Actor | Trusted with |
|---|---|
| Owner with the paired phone | Every decision; the app talks to app.* with its JWT |
Owner with an API key (dayhand) |
Listing and revoking connections (/connections*) |
| Claude app, Claude Code | Its own tokens after approval; nothing more. Its name comes from its metadata document on https://claude.ai |
| Any other MCP client | Same as Claude once approved, but its name is self-asserted (Dynamic Client Registration) |
| Anyone on the internet | Nothing: every /oauth/* endpoint is open to them by design |
The browser that opens the sign-in page is not trusted: it proves nothing about who uses it. The phone is what makes a request legitimate.
Threats and controls
| # | Threat | Control | Test |
|---|---|---|---|
| T1 | Code interception: an attacker gets an authorization code (a log, a redirect) and exchanges it | PKCE S256 required (no plain, no request without it); the code is bound to client, redirect URI and resource, lives 2 minutes and dies on its first presentation, right or wrong |
oauth.test.ts: wrong verifier burns the code; code bound to client, redirect URI and resource |
| T2 | Code replay: the same code is exchanged twice | A second presentation fails and revokes the connection the first one created | “a code used twice fails and revokes the connection” |
| T3 | Open redirect / code theft by redirect URI: a client registers https://evil/cb and gets the owner’s code |
Only Claude’s documented callbacks are accepted, at registration and in metadata documents: https://claude.ai/api/mcp/auth_callback exactly, and http://localhost / http://127.0.0.1 with path /callback on any port; no other host, scheme, path, query, fragment, credentials or IP spelling. Before the redirect URI is checked, errors are an error page, never a redirect |
oauthRules.test.ts (17 refused spellings), “a wrong redirect URI gets an error page” |
| T4 | Impersonation of Claude: a client calls itself “Claude” | The phone says Identified by claude.ai only for a metadata document fetched from an allowed origin (default https://claude.ai only); a self-registered client is shown as Registered itself (its name is not verified). Names are cleaned of control and bidi characters and cut to 60 |
Instrumented ConnectionsScreenTest, cleanName tests |
| T5 | Consent phishing: an attacker opens the sign-in page with their own client and sends the owner the code (“type this in Dayhand”) | The code works only on the owner’s phone and shows who asks and where the answer goes; a request older than 3 minutes gets a red warning; an answer to a program on the computer (loopback) gets a warning; the page and the app tell the owner to approve only a page they opened themselves. The approved code still goes to the page that asked, so the attacker must also hold the redirect URI: Claude’s own callback, or a loopback on the attacker’s machine | ConnectionsViewModelTest (“a request minutes old is flagged”), instrumented consent tests |
| T6 | Guessing the code typed on the phone | 8 Crockford base32 characters (40 bits), valid 10 minutes, at most 5 waiting per client; 10 wrong codes per user per 10 minutes, then the app refuses | “wrong codes are limited per user” |
| T7 | Token theft or guessing | Tokens are a selector plus 256 random bits, stored as SHA-256 only, compared in constant time, never logged or shown. Access tokens last one hour | — (format and storage by construction) |
| T8 | Refresh token theft | Rotation on every refresh; a rotated token used again more than 30 seconds later revokes the connection (both parties lose it, the owner sees it on the phone) | “reusing a refresh token rotated long ago revokes the connection” |
| T9 | Token for another server (confused deputy) | Every token is bound to <issuer>/mcp (RFC 8707); /mcp refuses a token bound to anything else; resource must name /mcp when sent |
“a token issued for another resource (audience) gets 401” |
| T10 | More access than granted | Scopes tasks:read and tasks:write only; no broad default (a request without scope gets reading only); the phone may grant a part, never more; tools/list is filtered and other tools, user resources and prompts get 403 insufficient_scope; a refresh can only narrow |
“a read-only connection…”, “a write-only connection…”, “a refresh may narrow the scopes” |
| T11 | Escalation from a connection: an agent mints API keys, pairs phones or approves itself | OAuth tokens work only on /mcp; /devices* and /connections* take API keys only; the approval needs the app’s JWT; identity fields in tool arguments are refused |
“OAuth tokens cannot manage devices or connections” |
| T12 | Revocation that does not bite | A revoked connection’s access and refresh tokens are refused on their next use, not when they expire | “revoking a connection on the phone or with dayhand stops its tokens at once”, RFC 7009 test |
| T13 | SSRF through metadata URLs | Only URLs on DAYHAND_OAUTH_CIMD_ORIGINS (default https://claude.ai) are fetched; no redirects, 16 KB, 5 seconds |
cimdUrlProblem tests, “metadata documents that are not acceptable…” |
| T14 | XSS, clickjacking, CSRF on the sign-in page | Every value is HTML-escaped; CSP default-src 'none' with a nonce for the one script and connect-src 'self'; frame-ancestors 'none', X-Frame-Options: DENY, form-action limited to the page and the redirect origin; the page’s forms carry a 256-bit request handle, so a cross-site form cannot continue someone else’s request |
“a client name is escaped on the page” |
| T15 | Secrets in logs or answers | No secret is logged; answers carry Cache-Control: no-store; connection views never contain hashes or tokens; errors are generic (invalid_grant) |
“revoking a connection on the phone or with dayhand…” checks that /connections returns no hash |
| T16 | Mix-up between authorization servers | iss in every authorization response (RFC 9207) |
Flow tests check iss |
| T17 | Denial of service by filling limits | Limits are per client where possible (waiting requests, request starts); global buckets are high flood guards; the token endpoint has no lockout. See residual risks | “too many waiting requests of one client…”, “failed code exchanges do not lock the token endpoint” |
| T18 | A runaway agent | 120 reads and 60 changes a minute per connection, as per key; the owner disconnects it on the phone | “calls are limited per connection” |
Residual risks
- Blocking Claude’s published identity. The
client_idof the Claude apps is public, so anyone can start 20 requests with it every 10 minutes and keep 5 waiting, which delays new Claude connections (not existing ones) for up to 10 minutes. The workaround is “Register automatically” (Dynamic Client Registration), whoseclient_idis private to that registration. The global flood guards (300 request starts per 10 minutes, 100 registrations per hour) can be filled the same way;npx convex run oauth:resetLimitsclears them. Nobody gets access this way. - Consent phishing with a loopback client. An attacker who persuades the owner to approve a code, despite the warnings, gets a connection through a loopback redirect on the attacker’s own machine. The warnings and the request’s age are the only defence; the owner sees the connection under Agent connections and can disconnect it.
- A stolen refresh token used within 30 seconds of the legitimate refresh is taken for a retry. The legitimate client’s next refresh then fails, and a later use of an old token revokes the connection.
- Metadata documents are fetched on every authorization request (no cache): at most 20 per
client per 10 minutes, to
claude.aionly. - Self-asserted names of registered clients can be anything printable; the app marks them as unverified.
- Revoking an app JWT (a lost phone) takes up to an hour (pairing.md); during
that hour the phone could still approve requests. Revoke the device with
dayhand revoke. - Not tested against the real Claude apps before a production deployment: Claude reaches
the server from Anthropic’s network over public HTTPS. The flows are tested with a simulator
(the MCP SDK’s OAuth client and
tests/oauthClient.ts) and on an emulator (android/scripts/e2e-connections.py).
Out of scope
Client secrets, confidential clients, the JWT bearer and client credentials grants, token introspection, OpenID Connect and sign-in on the web: Dayhand has no web accounts, and the paired phone is the only way in.
This page is docs/oauth-threat-model.md in the repository, where
you can suggest a change.