OAuth Security and Deployment

Deploying OAuth with the mcp-cpp-sdk requires careful attention to token management, network security, and issuer binding. This guide covers the operational controls and design rationale that developers and operators must understand to deploy OAuth safely.

Token Persistence and Storage

The mcp::auth::InMemoryTokenStore is thread-safe but volatile: tokens are lost when the process exits. Production deployments must not rely on it.

Implementing a Persistent TokenStore

Implement mcp::auth::TokenStore with encryption and OS-appropriate secret storage:

class MyTokenStore : public mcp::auth::TokenStore {
public:
    void store(const std::string& server_url,
               mcp::auth::TokenResponse token) override {
        // Encrypt token and persist to secure storage
        // (e.g., OS keychain, encrypted file, secret manager)
        auto encrypted = encrypt_token(token);
        persist_to_secure_storage(server_url, encrypted);
    }

    std::optional<mcp::auth::TokenResponse> load(
        const std::string& server_url) const override {
        // Retrieve and decrypt from secure storage
        auto encrypted = retrieve_from_secure_storage(server_url);
        if (!encrypted) return std::nullopt;
        return decrypt_token(*encrypted);
    }

    void remove(const std::string& server_url) override {
        // Delete from secure storage
        delete_from_secure_storage(server_url);
    }
};

Never log access tokens, refresh tokens, authorization codes, client secrets, or PKCE verifiers.

Credential-to-Issuer Binding

The mcp::auth::ClientCredentialStore is deliberately separate from mcp::auth::TokenStore because they use different storage keys:

  • TokenStore is keyed by MCP server URL.

  • ClientCredentialStore is keyed by authorization-server issuer.

This separation (SEP-2352) prevents a vulnerability where a single MCP server protected by different authorization servers over time could accidentally present one server’s credentials to another. One authorization server can also protect multiple MCP server URLs, so server-URL keying would eventually misattribute credentials.

Do not merge these stores. Use separate persistent implementations, keyed correctly, and validate that a stored credential’s issuer matches the issuer being contacted before presenting the credential. The SDK’s mcp::auth::select_client_identity() function validates the issuer binding automatically when reusing stored credentials; operators who implement custom storage must enforce the same check.

The Class That Applies These Controls

Everything above describes controls rather than the code that enforces them. mcp::auth::OAuthAuthorizationManager is that code, and the only supported way to act on a WWW-Authenticate challenge. It applies the fetch policy to every request it makes, binds the issuer byte-for-byte to the metadata document it was read from, generates and checks a cryptographic state, applies RFC 9207 iss validation before any error field in the response is read, carries S256 PKCE, and sends the RFC 8707 resource indicator into the code exchange.

Composing mcp::auth::OAuthDiscoveryClient and mcp::auth::OAuthHttpClient::exchange_code() into your own flow compiles and appears to work while applying none of them. That is the failure mode this guide exists to prevent: the fetch policy alone governs which targets are contacted, and says nothing about whether what comes back is bound to the issuer that was asked for.

Worked Flow

Taken from examples/features/oauth_flow.cpp, which runs end to end against a mock authorization server:

// A default-constructed policy refuses every metadata target: the allow list is empty
// and plain HTTP to a loopback address is not permitted. The application must name the
// origins it intends to reach before the first request. This example talks only to its
// own mock server on loopback, so it allows that one origin and opts in to plain-HTTP
// loopback. A client lists the origins of its MCP server and of the authorization servers
// that server names. Leaving allow_plain_http_loopback false admits only https targets,
// which the built-in client cannot reach yet because it has no TLS support.
mcp::auth::MetadataFetchPolicy policy;
policy.allowed_origins = {runtime.mock_oauth->issuer()};
policy.allow_plain_http_loopback = true;

mcp::auth::OAuthAuthorizationConfig auth_config;
auth_config.server_url = state->resource;
auth_config.client_id = "oauth-flow-example-client";
auth_config.redirect_uri = "http://localhost/callback";
auth_config.policy = std::move(policy);

state->token_store = std::make_shared<mcp::auth::InMemoryTokenStore>();

// The consent step. The SDK never launches a browser or binds a listener for the
// redirect; that is the application's job.
//
// DO NOT COPY THIS CALLBACK. It mints the code directly from the mock authorization
// server, which is only possible because this example owns both ends. A real client
// opens request.authorization_url, waits for the redirect to request.redirect_uri, and
// returns parse_authorization_response(redirect_url). `state` and `iss` are echoed here
// as a genuine authorization server would echo them; the manager rejects the response
// if they do not match what it recorded.
auto* mock_oauth = runtime.mock_oauth;
auto authorize = [mock_oauth](const mcp::auth::AuthorizationRequest& request)
    -> mcp::Task<mcp::auth::AuthorizationResponse> {
    std::cout << "[Client] Consent step for recorded issuer: " << request.issuer << '\n';
    std::cout << "[Client] Resource indicator: " << request.resource.value_or("<none>") << '\n';

    mcp::auth::AuthorizationResponse response;
    response.code = mock_oauth->issue_authorization_code(request.code_verifier);
    response.state = request.state;
    response.iss = request.issuer;
    co_return response;
};

state->manager = std::make_shared<mcp::auth::OAuthAuthorizationManager>(
    runtime.io_ctx->get_executor(), state->token_store, std::move(auth_config),
    std::move(authorize));

// What a conforming MCP resource server returns with its 401, per RFC 9728. Over an HTTP
// transport StreamableHttpSessionManager and HttpServerTransport send this header for you
// once set_protected_resource_metadata() and set_bearer_challenge() are configured; this
// example runs over MemoryTransport, so it renders the same header through the same public
// functions those transports call.
mcp::BearerChallengeConfig challenge;
challenge.scope = "mcp:demo";
challenge.resource_metadata =
    mcp::protected_resource_metadata_url(runtime.mock_oauth->resource_metadata());
const std::string www_authenticate = mcp::format_www_authenticate(challenge);

std::cout << "[Client] Acting on the resource server's WWW-Authenticate challenge\n";
if (!co_await state->manager->try_handle_challenge(www_authenticate)) {
    throw std::runtime_error("challenge carried no Bearer authorization to act on");
}

The consent callback in that example mints the authorization code directly from its own mock server, which is possible only because the example owns both ends. A real client opens request.authorization_url in the user’s browser, waits for the redirect to request.redirect_uri, and returns mcp::auth::parse_authorization_response(). It must never invent state or iss: the manager compares both against what it recorded and rejects a response that does not match.

After a successful challenge, mcp::auth::OAuthAuthorizationManager::last_authorization_request() returns the record the attempt was validated against, so an application can audit the binding rather than take it on trust.

See OAuth for the full API reference and for what the low-level building blocks do and do not provide.

Metadata-Fetch Policy and Origin Validation

Every OAuth metadata fetch — challenge-supplied resource_metadata URLs, protected-resource discovery, authorization-server discovery, and token-endpoint requests derived from discovered metadata — is an outbound-request primitive controlled by the mcp::auth::MetadataFetchPolicy.

Policy Default Behavior

A default-constructed policy denies every origin: mcp::auth::MetadataFetchPolicy::allowed_origins is empty, and an origin not explicitly listed is refused before host resolution. When the URL is refused, no socket is opened and no DNS lookup occurs.

mcp::auth::MetadataFetchPolicy policy;
// Every origin is denied; no metadata fetch succeeds without configuration.

Configuring Allowed Origins

List the exact origins your deployment intends to contact:

mcp::auth::MetadataFetchPolicy policy;
policy.allowed_origins = {
    "https://accounts.example.com",
    "https://auth.internal:8443",
    "https://protected-resource.example.org"
};

Comparison canonicalizes scheme and host case, a trailing run of host dots, a strictly-numeric explicit port that equals the scheme’s default (:443 for https, :80 for http — so :00443 and :0443 both canonicalize the same as no port at all), and an IP literal’s textual form (an expanded and a compressed IPv6 spelling of the same address compare equal). A non-default port and a genuinely different host still distinguish origins exactly. An allow-list entry that carries a path, query or fragment after the authority (e.g. "https://as.test/realms/foo") is not a bare origin and matches nothing.

Deny List Always Wins

The deny list is consulted before the allow list. An origin in both lists is refused:

policy.allowed_origins = {"https://auth.example.com"};
policy.denied_origins = {"https://auth.example.com"};
// Result: origin is DENIED
// Use this to temporarily block a server without removing it from the allow list.

Runtime Override with origin_allowance Callback

Applications that cannot enumerate all authorization servers in advance can implement the mcp::auth::MetadataFetchPolicy::origin_allowance callback. It is consulted only when an origin is not in the allow list, and its return value determines whether the origin is allowed:

policy.origin_allowance = [](const std::string& origin) {
    // Custom rule: allow https origins on example.com and its subdomains. The origin is
    // canonical ("https://host" or "https://host:port", default port elided), so compare the
    // host exactly or by a dot-prefixed suffix. A substring or prefix search would also admit
    // "https://example.com.attacker.test".
    constexpr std::string_view scheme = "https://";
    if (!origin.starts_with(scheme)) {
        return false;
    }
    std::string_view host = std::string_view(origin).substr(scheme.size());
    if (host.starts_with("[")) {
        return false;  // IP literals are not part of this rule
    }
    host = host.substr(0, host.find(':'));
    return host == "example.com" || host.ends_with(".example.com");
};

The callback:

  • Cannot relax scheme validation: it cannot permit plain http:// when the policy enforces HTTPS.

  • Cannot relax address validation: it cannot permit loopback, private ranges, link-local, multicast, or reserved addresses after they have been rejected by address classification.

  • Runs after the deny list, so denied origins stay denied.

  • Runs before host resolution, so a rejected origin never results in a socket opening.

Metadata URLs named by a protected resource are attacker-influenced input; the callback implements the rule that would otherwise be written as a complete allow list. Do not use it as a blanket escape hatch.

Issuer Comparison and Canonical Form

When an authorization server advertises support for RFC 9207 iss, the SDK validates that the issuer in the authorization response matches the issuer recorded from the selected authorization server’s metadata. This comparison is a plain byte-for-byte string equality check: no canonicalization is performed.

Why No Canonicalization

String equality without URL normalization might seem unsafe. Consider this threat:

  1. Attacker registers https://attacker.evil as an authorization server in operator’s metadata-fetch policy.

  2. Attacker directs victim to https://attacker-evil.com (note the difference).

  3. The two domains resolve to the same IP address or are aliased via DNS.

  4. If the SDK normalized URLs or resolved hostnames to compare, https://attacker.evil and https://attacker-evil.com might appear equivalent and bypass the issuer check.

  5. Attacker’s server could then issue tokens claiming to be from a trusted issuer, or replace legitimate issuer URLs with attacker-controlled variants.

By comparing only the byte sequence as written, the SDK ensures that:

  • An issuer URL is only accepted if it matches exactly what was advertised in the trusted metadata document.

  • Hostname canonicalization, default-port elision, trailing-slash normalization, or percent-encoding variations cannot defeat the binding.

  • No DNS-like resolution step can make two different issuer URLs appear equivalent.

The issuer binding is only as strong as the metadata document it comes from. The SDK validates the metadata URL itself (origin, address, redirect path) before fetching; that separate validation is your defense against SSRF attacks on the metadata endpoint.

Localhost Plain-HTTP Loopback Opt-Out

The mcp::auth::MetadataFetchPolicy::allow_plain_http_loopback flag permits plain http:// URLs and loopback addresses. This is a narrow opt-out intended only for loopback development and test fixtures such as the official MCP conformance runner.

// Development / test fixture only
mcp::auth::MetadataFetchPolicy policy;
policy.allow_plain_http_loopback = true;  // Narrow opt-out

Production deployments must NOT enable this flag. HTTPS is the default and the security boundary for OAuth.

Once enabled:

  • Plain http:// schemes are accepted for loopback addresses (127.0.0.1, ::1).

  • Loopback addresses themselves are unblocked (they are otherwise refused as private ranges).

  • This flag does not relax any other control: link-local, private non-loopback, multicast, reserved, or address-based denials still apply. Redirect chains, response sizes, and origin checks remain enforced.

  • It is never enabled implicitly; an operator must explicitly set it to true.

If you are integrating with the MCP conformance suite or a local test server, enable this flag only in the test or development configuration, never in production deployments.

SSRF and DNS-Rebinding Prevention

Metadata URLs are attacker-influenced input when they come from a challenge or a protected resource’s authorization_servers list. The mcp::auth::MetadataFetchPolicy includes controls to prevent Server-Side Request Forgery (SSRF) and DNS-rebinding attacks.

Blocked Address Classes

Before connecting to a resolved address, the SDK classifies it and refuses several ranges:

  • Link-local (169.254.0.0/16 for IPv4, fe80::/10 for IPv6): typically used for local-network autoconfiguration, not safe routing targets.

  • RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 for IPv4) and IPv6 unique-local (fc00::/7): internal network addresses.

  • Loopback (127.0.0.0/8 for IPv4, ::1 for IPv6): unless explicitly permitted by allow_plain_http_loopback.

  • Multicast and broadcast: not unicast-routable.

  • Reserved, unspecified, or non-routable: e.g., 0.0.0.0, 255.255.255.255, documentation prefixes.

Addresses written as IP literals in the URL are classified without any DNS lookup. A URL naming 169.254.169.254 or a private-range IP is refused immediately.

Resolve-Then-Pin

When a hostname must be resolved, the SDK performs a single lookup, classifies each resolved address, and connects only to addresses that pass the check. Critically, addresses obtained from that single resolution are pinned for the connection: a second resolution of the same hostname cannot redirect an established fetch, preventing DNS-rebinding attacks where:

  1. First lookup of attacker.com returns a public IP.

  2. Metadata fetch begins to that IP.

  3. Second lookup of attacker.com returns a private IP.

  4. Attacker tries to pivot the connection to the private IP.

This cannot happen: the first resolution’s results are used for the entire fetch.

Bounded and Re-Validated Redirects

HTTP redirects are followed up to a configurable limit (default 3):

policy.max_redirects = 3;  // Default; adjust as needed

Each redirect target URL is validated afresh against the scheme, origin, and address controls. A redirect chain cannot escape the policy by landing on a forbidden origin or address.

Response Size Caps

Metadata and token responses are capped to prevent unbounded buffering:

policy.max_response_bytes = 256 * 1024;  // Default: 256 KiB

A response exceeding the cap is rejected without being fully buffered, protecting against resource exhaustion. A server that sends slowly is bounded by the per-operation timeout instead, not by this cap.

Example: Hardened Policy Configuration

Once the SDK can reach https:// authorization servers (see the security boundary in OAuth; the built-in client has no TLS support yet), a hardened integration might configure:

mcp::auth::MetadataFetchPolicy policy;

// Explicit allow list of trusted authorization servers
policy.allowed_origins = {
    "https://accounts.example.com",
    "https://auth-backup.example.com"
};

// Temporary block during incident
policy.denied_origins = {"https://accounts.example.com:8080"};

// For discovered protected-resource metadata that names
// authorization servers not in the allow list, require explicit
// operator approval via a callback (optional; safer to omit)
policy.origin_allowance = [](const std::string& origin) {
    // Only allow exactly this https origin; a prefix check would also admit
    // "https://trusted.example.com.attacker.test".
    return origin == "https://trusted.example.com";
};

// Response limits
policy.max_response_bytes = 256 * 1024;
policy.max_redirects = 3;

// Never set allow_plain_http_loopback in production
policy.allow_plain_http_loopback = false;  // Explicitly documented

Give this policy to the authorization manager, which applies it to every discovery and token request it makes:

mcp::auth::OAuthAuthorizationConfig config;
config.server_url = "https://mcp.example.com/mcp";
config.client_id = "my-mcp-client";
config.redirect_uri = "https://app.example.com/callback";
config.policy = policy;

auto manager = std::make_shared<mcp::auth::OAuthAuthorizationManager>(
    executor, token_store, std::move(config), authorize);

An application driving an mcp::auth::OAuthHttpClient directly must install the policy on it, or every fetch is refused:

auto http_client = std::make_shared<mcp::auth::OAuthHttpClient>(executor);
http_client->set_metadata_policy(policy);

The fetch policy is only one of the controls an authorization flow needs. It stops the SDK contacting a target it should not, but it says nothing about whether the metadata that comes back is bound to the issuer that was asked for, whether the state echoed by the authorization server matches, or whether the RFC 9207 iss parameter is correct. mcp::auth::OAuthAuthorizationManager applies those; an application composing the low-level pieces itself does not get them. See What the manager validates.

Cross-References

  • See OAuth for the challenge-driven authorization flow and API reference.

  • See Transports for general HTTP transport security.

  • See Deployment Guide for overall production deployment patterns.