Skip to content

OAuth architecture

mcsinglewire’s authentication model has three goals:

  1. No long-lived bearer tokens on disk. The proxy holds a per-user refresh token, not a shared service-account credential.
  2. The Singlewire audit log reflects the actual operator. Each Claude Code session can be a different Singlewire user.
  3. Read-only access enforced before any code we write runs. The token Singlewire issues should not, at the protocol level, be capable of authorizing a write.

Goal 1 rules out the “service account whose API key sits in .env” pattern. Goal 2 rules out shared sessions at the proxy. Goal 3 is what makes everything else worth doing.

Claude Code ──HTTP/MCP──▶ mcsinglewire (FastMCP OAuth Proxy)
├─ PKCE handshake ─▶ Singlewire /api/login
│ /api/token
└─ Bearer ────────▶ Singlewire REST API
(per-user, refreshed automatically)

Three pieces:

  • Claude Code is the MCP client. It speaks MCP over Streamable HTTP to mcsinglewire.
  • mcsinglewire is two services in one process: the FastMCP server (which exposes the five tools) and FastMCP’s OAuth Proxy (which mediates the upstream OAuth flow).
  • Singlewire is the IdP and the resource server. It both issues the bearer and validates it on every API call.

FastMCP supports OAuth at two abstraction levels:

  • Built-in OAuth for clients that speak OAuth 2.1 directly. The MCP client talks OAuth to the MCP server.
  • OAuth Proxy for the case where the MCP server itself is the OAuth client to a third-party IdP (here, Singlewire). The MCP client doesn’t need to know Singlewire’s flow — it speaks OAuth 2.1 to the proxy, and the proxy speaks PKCE to Singlewire on its behalf.

We considered two alternatives and rejected both:

  • Raw bearer in .env. Violates goal 1 (long-lived secret on disk) and goal 2 (every session attributes to the same identity in Singlewire’s audit log).
  • OAuth sidecar process. Adds operational complexity for no real benefit — FastMCP’s OAuth Proxy already runs in-process and shares the lifecycle of the MCP server.

OAuth Proxy is the right shape. It:

  • Issues its own short-lived JWTs to the MCP client (signed with the persisted HMAC key in ${MCSINGLEWIRE_DATA_DIR}/signing.key).
  • Stores per-user Singlewire refresh tokens encrypted at rest.
  • Refreshes the upstream bearer transparently when it expires.
  • Forwards the upstream bearer to api_call’s outbound httpx call.

Singlewire offers three application types in the admin console:

TypeIssues secret?PKCE required?When to use
Native ClientnoyesDeveloper machines, OAuth Proxies, mobile apps
Generic ClientyesyesServer-side OAuth clients with a confidential secret
ServeryesnoServer-to-server with no user-in-the-loop

For mcsinglewire on a developer machine, Native Client is the right match. There’s no confidential secret to protect (PKCE provides equivalent assurance for the auth code exchange), and there’s no operational story for rotating one anyway.

For a hardened server-side deployment where you want a client secret as an additional factor, Generic Client also works — set MCSINGLEWIRE_UPSTREAM_CLIENT_SECRET and the proxy will include it in the token exchange. Server-type applications are not appropriate here because they bypass the user-attribution step that gives goal 2 above.

Worth being explicit about, because it sometimes surprises people:

  • The MCP transport itself is unauthenticated as an MCP transport. Anyone who can reach https://mcsinglewire.l.supported.systems/mcp can attempt to use the MCP tools. The OAuth flow guards upstream access, but the MCP listener should be deployed on a trusted network. The default caddy-docker-proxy setup at l.supported.systems is internal.
  • The proxy uses DebugTokenVerifier. It accepts any non-empty bearer at the proxy layer; validation is delegated to the upstream API (which returns 401 for invalid tokens). A Singlewire bearer issued for a different application could be presented here and forwarded — read access would still be bounded by that token’s scopes, and the read-only transport would still refuse writes. This is a deliberate trust model — see Safety model § 4.

Read-only is enforced at three independent layers. Each one is sufficient on its own; all three would have to be wrong for a write to reach Singlewire.

The OAuth application has only urn:singlewire:*:read scopes selected. The bearer Singlewire issues to mcsinglewire is, at the protocol level, incapable of authorizing a write. If api_call somehow constructed a POST and sent it, Singlewire would reject it with a 403 — there’s no scope on the bearer that authorizes the write.

This is the layer that survives even if everything in the mcsinglewire codebase is wrong. See OAuth scopes for the canonical list and the verification command.

Every api_call runs through five gates before any I/O:

  1. The operationId is known to the spec
  2. The operation’s method is GET
  3. The operation is not on the static denylist
  4. Every query_params key is declared in the spec
  5. path_params keys/types/values are valid; values are URL-quoted with safe=""

Gate 2 alone is sufficient for read-only. Gates 1, 4, and 5 protect against spec-confusion and injection attacks; gate 3 catches GETs that have side effects despite being GETs (the canonical example: getScenario’s “simulate answers” mode).

This layer is in code, with tests. See _validate_call in src/mcsinglewire/server.py and tests/test_server.py.

Below the application logic, the httpx client uses a custom transport whose handle_async_request method raises WriteAttemptError on any non-GET request, regardless of how the caller constructed it. Even a buggy api_call that somehow emitted a client.post(...) call gets stopped here.

This layer makes the read-only guarantee robust against future code changes. It’s the canonical guardrail; the wire-layer test in tests/test_client.py is never weakened without explicit justification recorded in the safety model.

What the “Invalid client_id” story taught us

Section titled “What the “Invalid client_id” story taught us”

Early on, an enabled OAuth application would still return Invalid client_id provided after a successful login because Singlewire returns the same error for disabled applications as for ones that don’t exist. The login itself worked — the failure was at the token exchange — and the surfaced error pointed in the wrong direction.

Two lessons:

  1. Diagnose by audit log, not by error message. Singlewire’s admin-console audit log will show a successful login but a failed token exchange when the application is disabled. The mcsinglewire logs alone aren’t enough — both sides have to be cross-referenced.
  2. Document the symptom for operators. The fix (flip the Enabled toggle on) is trivial; the search-engine-friendly write-up is what saves the next person twenty minutes. See Debug “Invalid client_id” for the full diagnostic flow.

For the user, none of this is visible. They run make up, point Claude Code at the MCP endpoint, and on first call get redirected to Singlewire’s login. After authenticating, the proxy holds a refresh token and Claude Code gets a JWT it can present to the proxy on subsequent calls. Every api_call from the LLM travels through the validation gates, hits the read-only transport, and arrives at Singlewire as a GET with a per-user bearer.

The LLM, the proxy, and Singlewire all see the same identity for a given session. When something is logged in Singlewire’s audit, it’s attributed to the human who logged in — not to “the MCP server”.