`claude mcp login` fails with OAuth `invalid_client` against Snowflake-managed MCP server, despite verified-valid credentials
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Title
claude mcp login fails with OAuth invalid_client against Snowflake-managed MCP server, despite verified-valid credentials
Environment
- Claude Code version: 2.1.218
- OS: Windows 11 Pro
- MCP server: Snowflake-managed MCP server (
TYPE = OAUTH,OAUTH_CLIENT = CUSTOM,OAUTH_CLIENT_TYPE = CONFIDENTIALsecurity integration) - Transport:
http, OAuth with local loopback callback
Summary
claude mcp login for an HTTP-transport MCP server backed by a Snowflake OAuth security integration always fails at the token-exchange step with an invalid_client OAuth error, even though the exact same client_id/secret pair is proven valid via direct calls to Snowflake's token endpoint (see isolation tests below). Reproduced identically across two independently-created Snowflake security integrations.
What Should Happen?
Token exchange succeeds; claude mcp list shows snowflake-poc as connected/healthy.
Actual behavior
Browser shows Claude Code's own "Authentication successful — you can close this tab" page.
Terminal reports: Couldn't complete authentication for "snowflake-poc": with no further detail.
claude mcp list / claude mcp get snowflake-poc continue to show "needs authentication" afterward.
Root cause (decoded via --debug-file)
Running with claude --debug-file <path> mcp login snowflake-poc shows the terse terminal error is actually:
Error during auth completion: nsr
nsr is a minified class name inside the compiled CLI bundle (@anthropic-ai/claude-code/bin/claude.exe) whose errorCode = "invalid_client" — confirmed by grepping the binary directly:
nsr=class nsr extends IT{};nsr.errorCode="invalid_client";
So the real failure is a standard OAuth invalid_client response from Snowflake's token endpoint during the authorization-code exchange.
Also notable in the debug log: the auth-completion step runs through Using pre-configured client ID → Returning code verifier → discovery-state save/return twice before finally failing — suggestive of an internal retry across different client-authentication methods (e.g. client_secret_basic then client_secret_post), possibly related to Fetched OAuth metadata with scope: NONE earlier in the log (minimal/incomplete AS metadata discovery from Snowflake).
Error Messages/Logs
Isolation tests ruling out Snowflake-side / credential causes
Credentials are valid: direct curl POST to the token endpoint with the identical client_id + secret, using a throwaway authorization code, returns invalid_grant (not invalid_client) via both client_secret_basic (HTTP Basic header) and client_secret_post (body params) — proving the client authenticates successfully at the protocol level regardless of method.
Not stale config: fully dropped and recreated the security integration (new client_id, new secret) — identical invalid_client failure reproduced, including after a real, successfully completed browser consent (not a throwaway code).
Not network interception: inspected the actual TLS certificate presented for the Snowflake host (.NET HttpWebRequest) — legitimate DigiCert-issued certificate for Snowflake Inc., no proxy/MITM substitution.
Not role blocking: the role granted in the consent screen (SYSADMIN) was never on BLOCKED_ROLES_LIST.
Not PKCE enforcement: tested with OAUTH_ENFORCE_PKCE both TRUE and FALSE, no difference (Claude Code sends PKCE regardless).
Confirmed via SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY: no login was ever authorized under this security integration across any attempt (AUTHORIZING_INTEGRATION_NAME never shows it) — consistent with failure always occurring before token issuance.
Additional context
A separate custom connector in Claude/Cowork (claude.ai-hosted OAuth flow, different redirect URI/implementation) against the same Snowflake integration shows a similar end-to-end failure pattern, though that's a distinct server-side OAuth client implementation from Claude Code's local loopback flow and hasn't been root-caused with the same level of detail.
Steps to Reproduce
- Create a Snowflake OAuth security integration (
CUSTOM/CONFIDENTIALclient) with a valid redirect URI registered for a local Claude Code callback, e.g.:
```sql
CREATE SECURITY INTEGRATION CLAUDE_MCP_OAUTH
TYPE = OAUTH
OAUTH_CLIENT = CUSTOM
OAUTH_CLIENT_TYPE = CONFIDENTIAL
OAUTH_REDIRECT_URI = 'https://claude.ai/api/mcp/auth_callback'
OAUTH_ALLOW_NON_TLS_REDIRECT_URI = TRUE
OAUTH_USE_SECONDARY_ROLES = IMPLICIT
PRE_AUTHORIZED_ROLES_LIST = ('MCP_POC_ROLE')
ENABLED = TRUE;
ALTER SECURITY INTEGRATION CLAUDE_MCP_OAUTH
SET OAUTH_ALTERNATE_REDIRECT_URIS = ('http://localhost:51234/callback');
claude mcp add --transport http snowflake-poc "https://<snowflake-account>.snowflakecomputing.com/api/v2/databases/SOURCE_DATA/schemas/MCP_POC/mcp-servers/POC_MCP_SERVER" --callback-port 51234 --client-id <client_id> --client-secret -s local
claude mcp add --transport http snowflake-poc "https://<snowflake-account>.snowflakecomputing.com/api/v2/databases/SOURCE_DATA/schemas/MCP_POC/mcp-servers/POC_MCP_SERVER" --callback-port 51234 --client-id <client_id> --client-secret -s local
claude mcp login snowflake-poc
Complete the browser consent screen (shows correct integration name, account, and role — consent itself succeeds).
Claude Model
Sonnet (default)
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.218
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
PowerShell
Additional Information
_No response_
4 Comments
Having a similar issue on mac with a similar connector type.
Hitting this exact same issue independently — Snowflake-managed MCP server, custom OAuth security integration, invalid_client at token exchange despite a successful browser consent. Adding what we found in case it helps narrow it down further, since some of it goes a bit beyond what's in the report above.
Environment: Custom OAuth security integration (OAUTH_CLIENT = CUSTOM), tested as both OAUTH_CLIENT_TYPE = PUBLIC and with real secrets generated for that same PUBLIC integration (OAUTH_CLIENT_SECRET/OAUTH_CLIENT_SECRET_2). Snowflake-managed MCP server resource under api/v2/.../mcp-servers/.... Federated SSO (non-Snowflake IdP) in front of Snowflake's own native OAuth authorization server.
Additional isolation we ran, on top of what's already reported here:
Same asymmetry as reported: a garbage/never-issued code returns invalid_grant; a real, freshly-issued code from a completed browser consent returns invalid_client instead — every time, sub-second exchange (rules out code expiry).
Tried every combination of: no secret / OAUTH_CLIENT_SECRET / OAUTH_CLIENT_SECRET_2, client_secret_post / client_secret_basic, scope=session:role:all / scope=session:role:<specific pre-authorized role>, RFC 8707 resource parameter present/absent, and all three of the integration's allowed authorization/token hostnames. All produce the identical invalid_client.
Confirmed the role active during every attempt was not on BLOCKED_ROLES_LIST.
Reproduced it with Snowflake's own official snowflake-connector-python (v4.7.1), using its native authenticator="OAUTH_AUTHORIZATION_CODE" support with PKCE — completely independent of Claude Code or any MCP client. Its own debug log shows the browser/consent leg completing successfully (step 2: received OAuth callback, requesting token), then the token request itself getting back the identical body:
{"data":null,"error":"invalid_client","code":null,"message":"This is an invalid client.","success":false,"headers":null}
The connector's own top-level exception (Invalid HTTP request from web browser...) is a misleading wrapper — the debug log makes clear the real cause is this invalid_client response, not a local listener problem.
Between this and the "not stale config" test already reported above (fresh integration, same failure), plus this now reproducing through Snowflake's own connector, it seems hard to explain by anything on the client side — Claude Code, a from-scratch curl/PKCE implementation, and Snowflake's own Python driver all hit the same wall against a real, validly-issued code.
We also checked SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORY for any record of these attempts, same idea as reported above, and got the same result: no row anywhere shows authorizing_integration_name matching our integration — not a success, not a failure — despite multiple real, successfully-completed browser consents within the same hour we ran the query (and we confirmed this isn't just ACCOUNT_USAGE ingestion lag; the most recent unrelated login rows were from the same few minutes as the query itself). Whatever rejects the token exchange happens at a point this view doesn't track at all.
Filing this as our own internal Snowflake support case too, but wanted to add it here in case Anthropic engineering has visibility into anything shared between our two setups.
Following up — we hit this exact same
invalid_clientsymptom (real, validly-issued code from a completed browser consent,invalid_grantonly for a garbage code, reproduced identically through Snowflake's ownsnowflake-connector-pythonindependent of Claude Code) and eventually got it fully resolved. Posting the root cause and fix in case it saves someone else the same multi-week detour.Root cause: network-policy precedence, not client auth. OAuth token exchange for a custom security integration is only evaluated against an account-level or integration-level network policy — never the user-level one that governs interactive/browser logins. Our integration was silently inheriting a restrictive account-level policy (effectively a deny-all for anything not explicitly listed) that had never been updated for this use case. Every symptom we chased — different scopes, different client-secret variants, different auth methods, different hostnames, zero rows in
SNOWFLAKE.ACCOUNT_USAGE.LOGIN_HISTORYfor the attempts — was consistent with a network-layer block happening before the request ever reached application logic, which is also why it never showed up in that view at all.Fix, allow Claude's actual egress IPs on the network policy attached to the integration:
Anthropic publishes their current outbound IP ranges here: https://platform.claude.com/docs/en/api/ip-addresses
Also needed, to get Claude Code itself (not just a manual script) authenticating: even after the network fix, Claude Code's own
claude mcp loginstill failed against aCONFIDENTIALclient. Claude Code currently has no mechanism to supply a client secret to an already-configured MCP server entry —MCP_CLIENT_SECRETis only ever read atclaude mcp addtime, not atlogin, and a policy-managed entry can't be locally re-added to trigger that path. So the actual fix that got everything working end to end was switching the integration toOAUTH_CLIENT_TYPE = PUBLIC(keepingOAUTH_ENFORCE_PKCE = TRUE) — Claude Code's built-in OAuth flow needs no secret and just works once the network policy above is in place.Worth being explicit:
PUBLIChere isn't a weaker/less-secure fallback — access is still gated by the network policy (only allowed IPs can reach the token endpoint at all), PKCE, and your existing SSO/IdP login requirement. Switching toPUBLICjust removes a secret Claude Code had no way to supply anyway; it doesn't expose the integration any further.Two things worth knowing if you go this route:
OAUTH_CLIENT_TYPEis immutable on an existing integration — switching requiresCREATE OR REPLACE SECURITY INTEGRATION, which issues a new client_id you'll need to update in your MCP config. And if Claude Code was already running, it caches the resolved MCP server config in memory — a config file edit alone won't be picked up until you fully quit and restart the CLI process, not just reconnect within the same session.(If you need to stay on
CONFIDENTIALfor policy reasons, aheadersHelperscript — a newer Claude Code MCP config option — can mint/refresh a bearer token server-side using a securely stored refresh token + the client secret, but it's meaningfully more setup and per-user renewal overhead than the above.)Hope this closes the loop for anyone else stuck on the same wall we were.
— Vinod Madigatla
Resolved on our end (root cause + fix in the comment above) — closing since this seems to have been dormant. Feel free to reopen if others are still hitting it.