Remote MCP servers increasingly require OAuth 2.0 instead of a static bearer token. Genesis runs the authorization-code flow from the Control UI, stores the resulting tokens encrypted on the gateway host, and refreshes them automatically.
Flow
sequenceDiagram
participant UI as Control UI
participant GW as Gateway
participant P as OAuth provider
UI->>GW: mcp.oauth.start
GW-->>UI: authorize URL (PKCE S256)
UI->>P: open popup to authorize URL
P-->>UI: redirect to /mcp-oauth-callback.html?code&state
UI->>UI: callback page postMessage(code, state)
UI->>GW: mcp.oauth.callback (code, state)
GW->>P: exchange code + verifier for tokens
GW-->>UI: connected
- The operator clicks Connect on an OAuth-required server in the Control UI.
mcp.oauth.startbuilds the provider authorize URL. Genesis generates a PKCE verifier and sends the S256 challenge; the verifier is bound to the requeststateand never leaves the gateway.- A popup opens the provider consent screen. The redirect URI sent with the
authorize request is built from the popup-opening browser's own origin (not
the gateway's statically resolved web URL), so it still resolves correctly
when the operator reaches the Control UI over a LAN address, tailnet
hostname, or tunnel domain that differs from the gateway's own view of
itself. After consent the provider redirects to that origin's
gateway-served callback page at
/mcp-oauth-callback.html. - The callback page posts the
codeandstateback to the Control UI. The UI only accepts that message when it comes from the gateway origin (see Callback origin checks). mcp.oauth.callbackexchanges the code (plus the PKCE verifier and any client secret) at the token endpoint and stores the resulting tokens.
Embedded browser flow
By default the operator authorizes in their own browser (the popup above). When
the Control UI is accessed remotely and the provider redirect cannot reach the
operator's browser, Genesis can instead run the sign-in inside a headless
browser on the gateway and stream it into its own popup window — the same UX
as the real-browser flow, just backed by a remote session. When the operator
clicks Connect, the UI tries mcp.oauth.embedded.start first and falls
back to the real-browser popup flow if the gateway reports it unavailable (or
if the streaming popup itself is blocked by the browser's popup blocker).
sequenceDiagram
participant UI as Control UI
participant GW as Gateway
participant B as Headless browser
participant P as OAuth provider
UI->>GW: mcp.oauth.embedded.start
GW->>B: launch, navigate to authorize URL (PKCE S256)
loop until done
UI->>GW: mcp.oauth.embedded.poll
GW-->>UI: phase + JPEG frame
UI->>GW: mcp.oauth.embedded.input (pointer/key)
GW->>B: relay input
end
P-->>B: redirect to /mcp-oauth-callback.html?code&state
GW->>P: exchange code + verifier for tokens
GW-->>UI: phase=done, connected
The embedded path reuses the same PKCE state and token exchange as the popup
flow — only the front end differs. The browser runs with an ephemeral profile
that is deleted on teardown, one flow per connection, and a hard timeout. All
four mcp.oauth.embedded.* methods are admin-scoped.
Enabling it
The gateway needs a Chromium/Chrome executable and the puppeteer-core package.
puppeteer-core is an optional runtime dependency that is not installed by
default; install it on the gateway host (pnpm add puppeteer-core, or your
package manager of choice) to enable the embedded flow. It is imported lazily,
so when it is absent the gateway simply reports the flow unavailable. Chromium
resolution order is the configured path, then PUPPETEER_EXECUTABLE_PATH /
CHROME_PATH / CHROMIUM_PATH, then platform defaults.
{
"mcp": {
"embeddedOAuth": {
"chromiumPath": "/usr/bin/chromium"
}
}
}
When no Chromium resolves (for example mobile-node or mac-app gateways), the embedded flow is unavailable and the UI uses the popup flow.
Security tradeoffs
Unlike the popup flow, provider credentials are entered into a browser running on the gateway host and screenshots of those pages stream over the connection. This is a larger trust surface: prefer it only on a gateway you control, always over TLS. Many providers also block headless browsers (captcha, device verification), in which case the sign-in will fail and you should use the popup flow or a provider that supports it. For a lighter alternative without credential transit, an RFC 8628 device-code flow is the recommended direction.
Token storage
Tokens live on the gateway host, never in the browser:
- File:
~/.genesis/mcp-oauth.json, written atomically with0600permissions. - Encryption: the file is encrypted at rest with AES-256-GCM under a
machine-local key at
~/.genesis/mcp-oauth.key(also0600). If the key is missing or the file cannot be decrypted, Genesis treats the server as not connected and never deletes the file, so an operator can investigate. - Redaction: server configs written to logs strip
auth.clientSecretand anyAuthorizationheader.
Legacy plaintext stores from earlier builds are read transparently and re-encrypted on the next write.
Refresh and revoke
- Tokens are refreshed automatically when a request finds them within 60 seconds of expiry, using the stored refresh token (RFC 6749 section 6). A new refresh token is adopted when the provider issues one; otherwise the existing one is kept.
mcp.oauth.refresh(admin scope) forces a refresh on demand.- Disconnecting a server makes a best-effort RFC 7009 revocation call when a
revokeUrlis known, then removes the local token. Revocation failures never block disconnect.
Discovery
When you add a server by link, Genesis probes it for OAuth requirements:
- The standard
/.well-known/oauth-authorization-serverand/.well-known/openid-configurationmetadata under the server origin. - RFC 9728
/.well-known/oauth-protected-resource, following the first advertised authorization server to its metadata. - An unauthenticated
initializeprobe. A401with aWWW-Authenticate: Bearerchallenge (or a JSON-RPC authorization error) marks the server as OAuth-required and, when present, theresource_metadatahint is followed.
Manual configuration
If a provider is not auto-detected, set the auth block directly under
mcp.servers.<name>.auth (JSON tab in the Control UI, or the config file):
{
"mcp": {
"servers": {
"notion": {
"url": "https://mcp.notion.com/mcp",
"auth": {
"authorizeUrl": "https://api.notion.com/v1/oauth/authorize",
"tokenUrl": "https://api.notion.com/v1/oauth/token",
"clientId": "your-registered-client-id",
"clientSecret": "your-client-secret",
"scopes": ["read", "write"],
"revokeUrl": "https://api.notion.com/v1/oauth/revoke",
"usePkce": true
}
}
}
}
}
clientIdmust be the id you registered with the provider. When omitted, Genesis falls back to a generic identifier that most providers will reject.usePkcedefaults totrue. Set it tofalseonly for providers that do not support PKCE.clientSecretis optional for public clients that rely on PKCE alone.
Self-hosted servers
Outbound metadata and OAuth requests are SSRF-guarded: by default Genesis blocks private, loopback, link-local, and other internal addresses, and pins DNS to prevent rebinding. To reach an MCP server on a private network, allow its hostname explicitly:
{
"mcp": {
"metadataFetch": {
"allowedHosts": ["mcp.internal.example"]
}
}
}
Listing a host exempts it from the private-address block. Leave this empty unless you operate the target server.
Callback origin checks
The callback page is served same-origin by the gateway. The Control UI accepts a callback message only when it carries the Genesis source tag and originates from the gateway web origin. When a browser reports an opaque origin for the popup, the UI falls back to verifying the message came from the popup window it opened. This prevents a malicious page from injecting a forged authorization code.