Skip to content

OAuth state with literal + causes "State does not match" — VS Code emits raw + on /authorize but parses + as space on callback (asymmetric encoding) #314715

@marianfoo

Description

@marianfoo

Does this issue occur when all extensions are disabled?: Yes

  • VS Code Version: 1.118.1
  • OS Version: macOS 26.4.1 (Apple Silicon)

Steps to Reproduce:

  1. Stand up any MCP HTTP server with OAuth + RFC 7591 dynamic client registration. Self-contained reproducer (zero deps, plain Node.js, no extension or workspace required) at . It registers a DCR client, opens a callback listener, and asks you to complete OAuth login in a browser.
  2. Run the reproducer multiple times — or once with a forced state value containing a literal + character. The script sends state=test%2Babc%2Bdef%3D%3D (correctly URL-encoded), so the server receives the value cleanly with literal +s. The OAuth provider echoes the state back. The reproducer's callback listener parses the result and reports byte-by-byte whether the value survived.
  3. Observe the callback URL: state=test+abc+def%3D%3D — the + characters are emitted as literal +, not %2B. VS Code's MCP OAuth client parses this URL form-urlencoded (+ → space), produces "test abc def==", compares to the originally-stored "test+abc+def==", sees a mismatch, and surfaces "An error occurred while signing in: State does not match."

Triggering this in normal use: configure VS Code Copilot against an OAuth-fronted MCP server that is not patched with a callback proxy, click "sign in", and expect failure on roughly half of attempts (whenever the random base64 state happens to contain a +).

Summary

VS Code's MCP OAuth client uses asymmetric URL encoding for the state parameter:

  • On send (/authorize URL): emits literal + characters in the query string.
  • On receive (OAuth callback URL): parses + as a space character (application/x-www-form-urlencoded semantics).

These two behaviors are inconsistent. Whenever the random base64 state happens to contain a + character (~50% of attempts; standard base64 alphabet includes + and /), the round-trip fails with An error occurred while signing in: State does not match.

This is the same symptom as the closed-but-unresolved #299238 — that issue was closed for missing trace logs, but the underlying defect is reproducible and likely the same root cause.

Empirical evidence

Spectrum test against an XSUAA-backed MCP server, sending each state URL-encoded properly (%2B, %2F) so the server-side ingress is unambiguous and we isolate the round-trip behavior:

Scenario State sent State VS Code-style parser sees Result
+ middle aaa+bbb== aaa bbb== ✗ Mismatch
+ leading +aaaaaaa== aaaaaaa== ✗ Mismatch
+ trailing aaaaaaa+== aaaaaaa == ✗ Mismatch
Multiple + a+b+c+d== a b c d== ✗ Mismatch
Realistic state w/ two + 6QadZ5GFXGvZ649+OuQi+Q== 6QadZ5GFXGvZ649 OuQi Q== ✗ Mismatch
/ middle aaa/bbb== aaa/bbb==
Plain alphanum aaaabbbb aaaabbbb

The bug is +-only, not a general encoding issue — /, =, alphanumerics all survive.

Why this is a client-side bug, not the OAuth provider's

Server-side workarounds (callback proxies that re-encode + as %2B before redirecting back to VS Code) are possible, and at least one is being implemented downstream (marianfoo/arc-1#214). But that's every OAuth-fronting server having to compensate independently for VS Code's asymmetry. The real bug lives in VS Code's outgoing-URL constructor versus its callback parser — a single client-side fix would close this bug class for every OAuth provider VS Code talks to.

Suggested fix

Generate OAuth state using URL-safe base64 (RFC 4648 §5: - instead of +, _ instead of /) rather than standard base64.

  • Same alphabet as PKCE code_challenge, which VS Code already uses correctly.
  • Same entropy.
  • No + or / to be ambiguous in URLs.
  • Closes the bug at the source for any OAuth provider, regardless of how they handle the callback.

Alternative if base64url isn't desired: URL-encode literal + as %2B in the outgoing /authorize URL.

Prior art (same bug class, different actors)

Related issues / what this isn't

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions