OAuth architecture
mcsinglewire’s authentication model has three goals:
- No long-lived bearer tokens on disk. The proxy holds a per-user refresh token, not a shared service-account credential.
- The Singlewire audit log reflects the actual operator. Each Claude Code session can be a different Singlewire user.
- 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.
The components
Section titled “The components”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.
Why FastMCP’s OAuth Proxy
Section titled “Why FastMCP’s OAuth Proxy”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.
Why Native Client vs Generic Client
Section titled “Why Native Client vs Generic Client”Singlewire offers three application types in the admin console:
| Type | Issues secret? | PKCE required? | When to use |
|---|---|---|---|
| Native Client | no | yes | Developer machines, OAuth Proxies, mobile apps |
| Generic Client | yes | yes | Server-side OAuth clients with a confidential secret |
| Server | yes | no | Server-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.
Trust boundaries
Section titled “Trust boundaries”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/mcpcan attempt to use the MCP tools. The OAuth flow guards upstream access, but the MCP listener should be deployed on a trusted network. The defaultcaddy-docker-proxysetup atl.supported.systemsis 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.
The three-layer enforcement model
Section titled “The three-layer enforcement model”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.
Layer 1: IdP (Singlewire)
Section titled “Layer 1: IdP (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.
Layer 2: Application (_validate_call)
Section titled “Layer 2: Application (_validate_call)”Every api_call runs through five gates before any I/O:
- The operationId is known to the spec
- The operation’s method is GET
- The operation is not on the static denylist
- Every
query_paramskey is declared in the spec path_paramskeys/types/values are valid; values are URL-quoted withsafe=""
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.
Layer 3: Wire (_ReadOnlyTransport)
Section titled “Layer 3: Wire (_ReadOnlyTransport)”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:
- 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.
- 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.
What this looks like in practice
Section titled “What this looks like in practice”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”.