Two parallel auth flows let third parties drive memo's tool surface (MCP, the same surface the owner UI uses). Both end at the same place — a bearer token that /api/mcp accepts — but the issuance, lifetime, and rotation differ.
| Concern | Personal Access Token | OAuth 2.0 Access Token |
|---|---|---|
| Prefix | mp_ | ma_ (refresh: mr_) |
| Issued by | Owner, by hand, at /settings/tokens | A registered OAuth client + the owner's consent |
| Default lifetime | Never expires (optional 1–1825 days) | 1 hour, refreshable for 30 days |
| Rotation | Manual (revoke + re-mint) | Automatic on refresh (single-use refresh tokens) |
| Audience | Local CLIs, scripts, your own automation | claude.ai, third-party apps, anything that needs end-user consent |
| Audit actor tag | mcp_pat:<token_id> | mcp_oauth:<access_token_id> |
| Revocation UI | /u/<username>/settings/tokens | /u/<username>/settings/connections |
/<your-username>/settings/tokens, click + New token, name it, tick the scopes, optionally set an expiry. The raw value — mp_<16-char public id>_<32-byte base64url secret> — is shown exactly once in a reveal modal with a clipboard-copy button.Authorization: Bearer mp_… on every /api/mcp request. memo hashes the raw value with sha256, does a timing-safe compare against the stored token_hash, and bumps last_used_at on a hit.revoked_at = now() atomically; subsequent requests return 401 immediately.| Scope | Tools it unlocks |
|---|---|
projects:read | list_projects, list_files, read_file, list_versions, diff_versions, read_version_file, list_agreements, read_agreement, list_connections, list_webhooks |
projects:write | create_project, delete_project, write_file, delete_file, publish, rename_project, set_project_notes, rollback_version, upload_files, create_agreement, delete_agreement, rename_username, revoke_connection, create_webhook, delete_webhook |
links:read | list_links, preview_url |
links:write | create_link, update_link, update_link_full, delete_link |
visits:read | get_visits, list_signatures, get_visit_events, get_replay_url, get_visit_recording_events, get_performance, get_utilization, export_visits_csv |
Scope checks happen inside every tool handler; tools throw unauthorized if the bearer doesn't carry the scope they need. The MCP endpoint returns this as JSON-RPC error code -32001.
mp_<16 base62 chars (public id)>_<43 base64url chars (32-byte secret)>
We store:
tokenHash — sha256 of the full raw token (no salting needed; the secret is 32 random bytes).tokenPrefix — mp_<first 8 chars of public id>… — what we show in lists so owners can recognise tokens at a glance without leaking the secret.That's enough to do a single-row indexed lookup on the hash and verify with timingSafeEqual (defence-in-depth — the DB equality already proves it).
Memo speaks RFC 6749 (Authorization Code), RFC 7636 (PKCE, S256 only), RFC 7591 (Dynamic Client Registration), RFC 7009 (Revocation), RFC 8414 (AS metadata), RFC 8707 (Resource Indicators), and RFC 9728 (Protected Resource Metadata).
That alphabet soup matters because claude.ai's "Add custom MCP" walks every one of those specs in order. If we only implemented the first three, the connector flow would silently fall apart at step 1 (the resource-metadata pointer is what tells claude.ai which authorize server to ask).
┌──────────────┐ ┌─────────────┐
│ claude.ai │ │ memo │
└──────┬───────┘ └──────┬──────┘
│ POST /api/mcp (no bearer) │
├─────────────────────────────────────────────────────►│
│ 401 + WWW-Authenticate: Bearer ... │
│ resource_metadata="…/.well-known/...protected…" │
│◄─────────────────────────────────────────────────────┤
│ GET .well-known/oauth-protected-resource │
├─────────────────────────────────────────────────────►│
│ { authorization_servers: ["https://memo…"] } │
│◄─────────────────────────────────────────────────────┤
│ GET .well-known/oauth-authorization-server │
├─────────────────────────────────────────────────────►│
│ { authorize, token, register, revoke URLs } │
│◄─────────────────────────────────────────────────────┤
│ POST /oauth/register { client_name, redirect_uris } │
├─────────────────────────────────────────────────────►│
│ 201 { client_id: "mc_…", ... } │
│◄─────────────────────────────────────────────────────┤
│ → opens https://memo.../oauth/authorize?... │
│ (Clerk sign-in if not authed, then consent UI) │
│ ← 303 redirect_uri?code=mac_…&state=… │
│ POST /oauth/token (grant=authorization_code, │
│ code, code_verifier, redirect_uri) │
├─────────────────────────────────────────────────────►│
│ 200 { access_token: "ma_…", │
│ refresh_token: "mr_…", │
│ expires_in: 3600, ... } │
│◄─────────────────────────────────────────────────────┤
│ POST /api/mcp (Authorization: Bearer ma_…) │
│ → works │
│ │
│ (1h later) │
│ POST /oauth/token (grant=refresh_token) │
├─────────────────────────────────────────────────────►│
│ 200 with NEW access_token + NEW refresh_token │
│ (old refresh_token marked rotated, single-use) │
│◄─────────────────────────────────────────────────────┤
plain PKCE method. S256 only. PKCE-plain is essentially no protection at all for public clients.client_secret — and the discovery doc advertises token_endpoint_auth_methods_supported: ["none"] so well-behaved clients won't ask for one.Every refresh exchange:
client_id match, no revoked_at, no rotated_at (single-use), and not expired.rotated_at = now() on the old row.issueTokenPair.If a client replays an already-rotated refresh token, we 400 with invalid_grant and the client must re-do the full authorize flow. This is the canonical defense against refresh-token theft.
When an owner clicks Revoke on a connection at /u/<username>/settings/connections:
revoked_at on the targeted access token.revoked_at on every refresh token for the same (client_id, user_id) pair.So a single revoke kills the entire connection — the client can't silently mint another access token even if it kept a stale refresh token.
| Threat | Mitigation |
|---|---|
| Token leaked from a backup / scraped from git history | sha256-only storage; raw never persisted |
| Stolen access token | 1h TTL (OAuth) or per-token revocation (PAT) |
| Replayed refresh token | Single-use rotation; replay invalidates the chain |
| Compromised OAuth client | Per-user-per-client revocation at /settings/connections |
| Cross-client confused-deputy | Authorization codes bound to (client_id, redirect_uri, PKCE) |
| Scope escalation | Tools check scopes; consent UI only requests what the AS-metadata advertises |
| Phishing on the consent page | Renders the registered client name + URL + logo so users see what they're approving |
MITM on /api/mcp | HSTS preload + bearer auth + WWW-Authenticate pointing back to discovery |
See Security for the cross-cutting threat model.