-
Notifications
You must be signed in to change notification settings - Fork 1
Security
This page describes the proxy's security model and the defenses built into it. For the code that implements these checks, see Architecture, Bridge Internals, and OAuth2 Internals.
- The proxy holds OAuth2 credentials and acquires tokens on behalf of the configured client. It does not perform per-user delegation beyond whatever the single configured grant represents.
- Tokens live in memory in a single
TokenManager. Only the refresh token (forauthorization_code) is persisted, and only encrypted.
The proxy is the sole authority for the upstream Authorization header. It sets
Authorization: Bearer <token> on every upstream request itself; the MCP client
never needs to — and cannot usefully — inject one through the stdio channel.
- stdout is reserved exclusively for JSON-RPC. Anything else on stdout would corrupt the MCP stream, so all diagnostics go to stderr via pino.
- Logs redact secrets:
access_token,refresh_token,client_secret,Authorizationheaders, and theoauth2.clientSecret,oauth2.refreshToken,oauth2.authorizationCode, andoauth2.codeVerifierconfig fields are all replaced with[redacted].
Cleartext HTTP is rejected unless it is provably safe:
-
https://URLs always pass. -
http://URLs pass only if the host is loopback (localhost,127.0.0.0/8,::1) — useful for local dev and SSH tunnels — or if you have explicitly setallowInsecureHttp: true(ALLOW_INSECURE_HTTP=true). - Any other cleartext URL throws at startup.
This check (assertSecureUrl) runs at load time against upstream.url,
oauth2.tokenUrl, and oauth2.authorizationUrl. Discovered endpoints get the
same treatment via keepSecure (insecure ones are dropped — see Discovery).
Enable
allowInsecureHttponly for trusted local testing. In production, always usehttps://end to end.
The local OAuth callback server used by the authorization_code flow defends
against two classes of attack:
-
DNS rebinding. The listener rejects any request whose
Hostheader is not a loopback address or whose port doesn't match the bound port. A remote page that resolves a hostname to127.0.0.1therefore can't drive the callback. -
CSRF. The OAuth
stateparameter is generated randomly and validated on the redirect; a mismatch is rejected. - It warns if you bind the callback to a non-loopback host
(
callbackHost), since that widens exposure.
PKCE (S256) is always used, so an intercepted authorization code is useless without the verifier.
- Encrypted with AES-256-GCM; the key is a random 32-byte file stored with
0600permissions. - Stored under the per-user OS config directory (see OAuth2 Grants and Tokens).
- Caveat: this protects against casual disk/backup exposure, not against an attacker who already has read access to your user account — the key sits beside the ciphertext by design (so launches stay non-interactive). Treat the config directory like any other credential store: rely on OS file permissions and full-disk encryption.
- Prefer environment variables for secrets so they don't persist in a config file.
- Keep the proxy and the upstream on
https://(or a loopback SSH tunnel) at all times. - Set
upstream.timeoutMssensibly to bound how long a hung upstream can tie up a request.
GitHub repo · npm package · Licensed under MIT
Overview
Guides
- Getting Started
- Configuration
- OAuth2 Grants and Tokens
- Discovery
- Security
- Remote Hosts (SSH Port Forwarding)
- Troubleshooting
Internals