MCP Client Metadata (CIMD): Hosting, Server Implementation, and CIDR/SSRF Security

This guide is for owners of remote HTTP MCP clients, MCP servers, and the Signet authorization server. It shows how to publish a Client ID Metadata Document, expose OAuth Protected Resource Metadata from an MCP server, configure Signet, validate the resulting access token, and safely fetch attacker-controlled metadata URLs.

CIMD is not CIDR. CIMD means Client ID Metadata Document: a public HTTPS JSON document whose URL is also an OAuth client_id. CIDR is IP prefix notation such as 10.0.0.0/8; it appears only in the SSRF protection section of this guide. An MCP owner does not submit a CIDR block to register a client.

The MCP 2026-07-28 authorization specification references CIMD draft-00. Signet implements a deliberately constrained profile of that mechanism, not full conformance with every revision of the evolving latest CIMD draft. This page documents Signet’s actual behavior and calls out known differences where they affect deployment.

Architecture and Ownership

Four roles participate in the flow. One organization may operate more than one role, but the documents remain distinct.

Role Responsibility Document it owns
MCP client Opens the authorization flow, generates PKCE, and calls the MCP server The client owner hosts the CIMD JSON
Client metadata origin Serves the CIMD URL directly over public HTTPS https://client.example.com/oauth/client.json
MCP server / OAuth resource server Challenges unauthenticated requests and validates access tokens RFC 9728 Protected Resource Metadata (PRM)
Signet / authorization server Resolves the CIMD URL, obtains consent, and issues tokens OAuth authorization-server metadata and JWKS

The two metadata documents most often confused are:

  • CIMD describes the OAuth client. Its URL is the client_id, and the MCP client owner publishes it.
  • Protected Resource Metadata (PRM) describes the MCP resource server and points to Signet. The MCP server owner publishes it, normally at /.well-known/oauth-protected-resource.

Example Deployment

Choose stable canonical identifiers before writing code:

Purpose Example
Signet issuer https://auth.example.com
MCP resource identifier https://mcp.example.com
MCP protected-resource metadata https://mcp.example.com/.well-known/oauth-protected-resource
CIMD URL and OAuth client_id https://client.example.com/oauth/client.json
Client callback https://client.example.com/oauth/callback

Treat every identifier as a byte-exact value. A missing or extra trailing slash is a different identifier. Use a lower-case canonical https URL, even though URI scheme matching is case-insensitive, and do not derive these values from an untrusted Host or X-Forwarded-Host request header.

The complete discovery and authorization sequence is:

sequenceDiagram participant C as MCP client participant R as MCP server participant A as Signet participant O as Client metadata origin participant B as Browser C->>R: Request without an access token R-->>C: 401 + WWW-Authenticate resource_metadata URL C->>R: GET /.well-known/oauth-protected-resource R-->>C: resource + authorization_servers C->>C: Require PRM resource exact match, then select a trusted AS C->>A: GET /.well-known/oauth-authorization-server A-->>C: endpoints + client_id_metadata_document_supported C->>C: Require metadata issuer exact match, then record issuer C->>C: Generate state, code_verifier, and S256 challenge C->>B: Open /oauth/authorize with CIMD URL as client_id B->>A: Authorization request A->>O: GET the client_id URL O-->>A: 200 metadata JSON A->>B: Login and consent A-->>B: Redirect with authorization code B-->>C: Callback with code, state, and iss C->>C: Validate state and iss before using the code C->>A: POST /oauth/token with code_verifier and resource A-->>C: Access token whose aud names the MCP resource C->>R: Authorization: Bearer access_token R-->>C: MCP response

Part 1: Publish the Client ID Metadata Document

1.1 Create the JSON document

For the example deployment, publish this exact body at https://client.example.com/oauth/client.json:

{
  "client_id": "https://client.example.com/oauth/client.json",
  "client_name": "Acme MCP Client",
  "client_uri": "https://client.example.com",
  "redirect_uris": ["https://client.example.com/oauth/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "scope": "openid profile email offline_access"
}

Signet consumes the following fields and ignores unknown JSON members:

Field Required Signet behavior
client_id Yes Must be byte-for-byte equal to the URL Signet fetched
client_name No Display name; the document hostname is used when empty
client_uri No Stored as descriptive client information
redirect_uris Yes One to ten entries; the callback must match one exactly
token_endpoint_auth_method No Empty or none only; CIMD clients never have a shared secret
grant_types No When present, must contain authorization_code
scope No Intersected with Signet’s user-safe scope set

Important constraints:

  • A CIMD client is always public and uses the authorization-code grant with S256 PKCE for initial user authorization; Signet does not enable device or client-credentials grants for it. A refresh grant may still be declared and used when refresh tokens are enabled.
  • The metadata URL must use HTTPS, contain a hostname and a path more specific than /, contain no user information or fragment (including an empty trailing #), and contain no . or .. path segment.
  • Signet recognizes the HTTPS scheme case-insensitively when deciding whether a value has CIMD shape. The later document binding is still a byte-exact comparison, so the document’s client_id must preserve the spelling used by the client. Prefer a lower-case canonical https URL everywhere.
  • Redirect URIs use exact matching. With STRICT_REDIRECT_URIS=true, production callbacks must use HTTPS; loopback development callbacks may use HTTP.
  • Signet accepts at most 64 KiB. The current CIMD draft recommends keeping the document below 5 KB, which is a good production target.
  • Do not put a secret, bearer token, or private key in the document or its URL. The document and client_id are public.
  • The draft says a client identifier URL should not contain a query string. Signet accepts one for compatibility, but new clients should not use it. Also avoid aliases and slash redirects. A short, stable URL prevents exact-match and migration problems.

The document’s scope and grant_types describe what the client may use; they do not request a scope, force Signet to issue a refresh token, or replace parameters in an authorization request.

1.2 Implement the metadata origin in Go

The metadata endpoint must return the document directly with status 200. It must not authenticate the request or redirect to another URL.

package main

import (
    "log"
    "net/http"
    "time"
)

const clientMetadata = `{
  "client_id":"https://client.example.com/oauth/client.json",
  "client_name":"Acme MCP Client",
  "client_uri":"https://client.example.com",
  "redirect_uris":["https://client.example.com/oauth/callback"],
  "token_endpoint_auth_method":"none",
  "grant_types":["authorization_code","refresh_token"],
  "scope":"openid profile email offline_access"
}`

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/oauth/client.json", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            w.Header().Set("Allow", http.MethodGet)
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        // Keep the canonical client_id in configuration or source. Do not build
        // it from r.Host, Forwarded, or X-Forwarded-* headers.
        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        w.Header().Set("Cache-Control", "public, max-age=300")
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(clientMetadata))
    })

    server := &http.Server{
        Addr:              ":443",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    // In production, use a trusted public certificate. If a reverse proxy
    // terminates TLS instead, listen on its private upstream port with plain
    // HTTP and keep the externally visible canonical URL unchanged.
    log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}

The response does not need CORS for Signet’s server-to-server fetch. Add CORS only if a separate browser use case requires it.

1.3 Serve a static document with Nginx

For a static origin, avoid a generic trailing-slash or www redirect on this exact location:

server {
    listen 443 ssl;
    server_name client.example.com;
    root /srv/cimd;

    ssl_certificate     /etc/letsencrypt/live/client.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/client.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location = /oauth/client.json {
        default_type application/json;
        add_header Cache-Control "public, max-age=300" always;
        add_header X-Content-Type-Options "nosniff" always;
        try_files /client.json =404;
    }
}

Verify the public endpoint before using it:

set -euo pipefail
body="$(mktemp)"
trap 'rm -f "$body"' EXIT

status="$(curl --proto '=https' --noproxy '*' --silent --show-error --max-redirs 0 \
  --connect-timeout 5 --max-time 10 --max-filesize 65536 \
  --output "$body" --write-out '%{http_code}' \
  https://client.example.com/oauth/client.json)"
test "$status" = "200"
test "$(wc -c < "$body")" -le 65536
jq -e '.client_id == "https://client.example.com/oauth/client.json"' "$body"

Run this check only against the metadata origin you control, not an authorization-request URL supplied by another party. It is not an SSRF guard. The status check passes only for a direct 200 response. A 301, 302, authentication page, or TLS error makes the client unusable. If all usable DNS answers are non-public, Signet blocks the connection; mixed public/private answers are an invalid and unreliable deployment, so make every A and AAAA answer public.

Part 2: Implement the MCP Resource Server

The MCP server is an OAuth resource server. It publishes Protected Resource Metadata, challenges unauthenticated callers, and validates access tokens. It normally does not host the client’s CIMD document.

2.1 Publish RFC 9728 Protected Resource Metadata

For the example deployment, return:

{
  "resource": "https://mcp.example.com",
  "authorization_servers": ["https://auth.example.com"],
  "bearer_methods_supported": ["header"],
  "resource_name": "Acme MCP Server"
}

The MCP client must validate discovery before trusting any endpoint:

  1. Derive and record the expected canonical resource identifier from the MCP server it intended to contact.
  2. Fetch PRM and require its resource value to equal that identifier using simple string comparison.
  3. Apply local trust policy to authorization_servers; do not treat an arbitrary issuer supplied by an untrusted server as trusted automatically.
  4. Fetch the selected authorization server’s metadata using RFC 8414/OIDC discovery rules and require its issuer to equal the selected authorization-server identifier exactly.
  5. Require client_id_metadata_document_supported: true before starting a CIMD flow.
  6. Store the validated issuer together with the resource, state, and PKCE verifier for authorization-response validation.

Do not use authorization or token endpoint URLs from metadata until these checks pass. They prevent resource-server impersonation and authorization-server mix-up.

A simple single-AS policy is an exact allowlist containing only https://auth.example.com. Avoid hostname-suffix wildcards, and keep the allowlist independent from values supplied by the MCP server being discovered. The canonical MCP resource is also a configured deployment identifier; do not invent a client-side normalization algorithm for paths, ports, or slashes.

A minimal Go implementation is:

package mcp

import (
    "context"
    "encoding/json"
    "net/http"
    "strings"
)

const (
    resourceID       = "https://mcp.example.com"
    resourceMetadata = resourceID + "/.well-known/oauth-protected-resource"
    signetIssuer     = "https://auth.example.com"
)

type VerifiedClaims struct {
    Subject  string
    ClientID string
    Scopes   map[string]bool
}

type TokenVerifier interface {
    VerifyAccessToken(ctx context.Context, rawToken string) (*VerifiedClaims, error)
}

type verifiedClaimsKey struct{}

func ClaimsFromContext(ctx context.Context) (*VerifiedClaims, bool) {
    claims, ok := ctx.Value(verifiedClaimsKey{}).(*VerifiedClaims)
    return claims, ok
}

func ProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("Cache-Control", "public, max-age=300")
    _ = json.NewEncoder(w).Encode(map[string]any{
        "resource":                 resourceID,
        "authorization_servers":   []string{signetIssuer},
        "bearer_methods_supported": []string{"header"},
        "resource_name":            "Acme MCP Server",
    })
}

func bearerToken(r *http.Request) string {
    fields := strings.Fields(r.Header.Get("Authorization"))
    if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") {
        return ""
    }
    return fields[1]
}

func RequireAccessToken(verifier TokenVerifier, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := bearerToken(r)
        if token == "" {
            w.Header().Set(
                "WWW-Authenticate",
                `Bearer resource_metadata="`+resourceMetadata+`"`,
            )
            http.Error(w, "missing bearer token", http.StatusUnauthorized)
            return
        }

        // Verify signature and claims as described in section 2.4 before
        // allowing the request to reach the MCP handler.
        claims, err := verifier.VerifyAccessToken(r.Context(), token)
        if err != nil {
            w.Header().Set(
                "WWW-Authenticate",
                `Bearer error="invalid_token", resource_metadata="`+
                    resourceMetadata+`"`,
            )
            http.Error(w, "invalid bearer token", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), verifiedClaimsKey{}, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Register ProtectedResourceMetadata at GET /.well-known/oauth-protected-resource. If the resource identifier contains a path, follow RFC 9728’s well-known URI construction rules rather than concatenating strings.

2.2 Return the OAuth challenge

An MCP request without credentials must receive HTTP 401 and a discovery pointer:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

Use 401 for a missing, expired, malformed, or otherwise invalid access token. After successful verification, read ClaimsFromContext in the operation handler and apply its scope and local policy. A valid token that lacks permission receives 403, not 401:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="REQUIRED_SCOPE", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

Never put internal verification details into the response body. Return all scopes needed for the operation in one challenge so the client does not enter repeated step-up loops.

If a browser-based MCP client must read the challenge across origins, expose the header on the MCP server:

Access-Control-Expose-Headers: WWW-Authenticate

Allow only the required origins, methods, and request headers. Signet’s CORS configuration affects Signet endpoints; it does not configure CORS on your MCP server.

2.3 Preserve the resource indicator

The MCP client sends the resource identifier in both authorization and token requests. This binds the resulting access token audience to the MCP server:

resource=https://mcp.example.com

Use exactly the same string in:

  • PRM’s resource field;
  • the client’s authorization request;
  • the client’s token request;
  • CIMD_ALLOWED_RESOURCES in Signet; and
  • the MCP server’s accepted aud value.

Do not silently normalize a trailing slash at only one layer.

2.4 Validate every access token

Before executing any MCP operation, the resource server must:

  1. Accept the token only from the Authorization: Bearer header.
  2. Select keys from https://auth.example.com/.well-known/jwks.json and allow only the configured signing algorithm; do not trust the token’s alg by itself.
  3. Verify the signature, iss, exp, and nbf when present.
  4. Require Signet’s type claim to be access; reject refresh tokens and ID tokens.
  5. Require aud to contain the byte-exact resource identifier https://mcp.example.com, whether aud is encoded as a string or an array.
  6. Enforce operation-specific scopes and local authorization policy.
  7. Cache JWKS according to its response headers and handle key rotation safely.

See JWT Verification for complete Go, Python, and Node.js verification examples.

Offline JWT verification cannot observe a later database revocation by itself. Deleting or disabling a Signet client prevents subsequent grants, code exchange, or refresh as applicable, but a previously issued access JWT remains cryptographically valid until exp. If immediate access-token revocation is a requirement, add a resource-server denylist/status check or online introspection design, and keep access-token lifetimes short. Signing-key rotation has a much wider blast radius and should not be the normal per-client revocation mechanism.

Part 3: Configure Signet and Run the Flow

3.1 Enable CIMD deliberately

This is a CIMD-related production excerpt, not a complete Signet deployment file. RS256 also requires a private signing key, and every production deployment requires a unique session secret:

ENVIRONMENT=production
BASE_URL=https://auth.example.com
JWT_SIGNING_ALGORITHM=RS256
JWT_PRIVATE_KEY_PATH=/run/secrets/signet-jwt-private.pem
SESSION_SECRET=session-secret-change-in-production
JWT_AUDIENCE=
JWT_EXPIRATION=15m
STRICT_REDIRECT_URIS=true
ENABLE_REFRESH_TOKENS=true             # Optional; disable if refresh is not needed

CIMD_ENABLED=true
CIMD_ALLOWED_RESOURCES=https://mcp.example.com
CIMD_FETCH_TIMEOUT=5s
CIMD_CACHE_TTL=5m
CIMD_ALLOW_PRIVATE_NETWORKS=false

Production validation deliberately rejects the shown SESSION_SECRET placeholder. Before startup, generate a value with openssl rand -hex 32 and inject it through the deployment’s secret manager. Do not commit the session secret or JWT private key.

Database, listener/TLS, proxy, health-check, and other standard deployment settings are outside this CIMD-specific excerpt; complete them using Signet’s Configuration Guide. Choose an access-token lifetime that matches your revocation design; the shown short lifetime reduces the window for an offline JWT that is revoked after issuance.

For a browser-based MCP client that calls Signet from another origin, also configure Signet’s CORS allowlist explicitly:

CORS_ENABLED=true
CORS_ALLOWED_ORIGINS=https://client.example.com

Configuration behavior:

Setting Meaning
CIMD_ENABLED Opt-in global gate; default is false
CIMD_ALLOWED_RESOURCES Comma-separated, exact-match global allowlist; empty means deny all non-empty CIMD resource requests
CIMD_FETCH_TIMEOUT Overall metadata-fetch timeout; default 5s
CIMD_CACHE_TTL Positive-document cache cap; default 5m, with a one-minute floor
CIMD_ALLOW_PRIVATE_NETWORKS Disables the address guard; use only for isolated development and never in production

Keep JWT_AUDIENCE empty or set it to an authorization-server-only identifier. Do not set it to the MCP resource identifier: a refresh token carrying that default audience must never be accepted by the resource server. The request’s RFC 8707 resource parameter supplies the access-token audience.

For multiple MCP servers, list exact resource identifiers separated by commas:

CIMD_ALLOWED_RESOURCES=https://mcp.example.com,https://reports.example.com

When enabled, Signet advertises the capability in its authorization-server metadata:

curl --fail --silent \
  https://auth.example.com/.well-known/oauth-authorization-server \
  | jq '.client_id_metadata_document_supported'

The result must be true. Absence or false means the client must use another supported registration mechanism.

3.2 Build the authorization request

The client generates at least 128 bits of cryptographically secure randomness for a fresh state. Generate the PKCE code_verifier independently from at least 256 bits of cryptographically secure randomness, encode it with the RFC 7636 unreserved character set (base64url without padding is convenient), and keep it between 43 and 128 characters. Never reuse either value. Store them in a short-lived, single-use flow record and derive the S256 code_challenge. The client then opens a URL equivalent to:

https://auth.example.com/oauth/authorize?
  response_type=code&
  client_id=https%3A%2F%2Fclient.example.com%2Foauth%2Fclient.json&
  redirect_uri=https%3A%2F%2Fclient.example.com%2Foauth%2Fcallback&
  scope=openid%20profile%20email&
  resource=https%3A%2F%2Fmcp.example.com&
  state=RANDOM_STATE&
  code_challenge=BASE64URL_SHA256_VERIFIER&
  code_challenge_method=S256

Construct the query with a URL library rather than concatenating strings. Store the validated expected issuer, resource identifier, state, and code_verifier in one per-flow session record.

On every success and error callback:

  1. Require state to match that per-flow record.
  2. Because Signet advertises authorization_response_iss_parameter_supported: true, require an iss parameter.
  3. Compare the decoded iss to the recorded metadata issuer using byte-exact simple string comparison. Do not case-fold, remove a default port, normalize percent encoding, or change a trailing slash.
  4. Reject a missing or mismatched iss before sending a code to any token endpoint or displaying an authorization error.

During this request Signet fetches and validates the CIMD document, creates or refreshes an internal shadow client, and shows consent. The consent screen identifies the client by its document domain because the self-asserted client_name is not proof of identity.

3.3 Exchange the code

The public client authenticates with client_id and PKCE, not a secret:

curl --request POST https://auth.example.com/oauth/token \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'client_id=https://client.example.com/oauth/client.json' \
  --data-urlencode 'redirect_uri=https://client.example.com/oauth/callback' \
  --data-urlencode 'code=AUTHORIZATION_CODE' \
  --data-urlencode 'code_verifier=ORIGINAL_CODE_VERIFIER' \
  --data-urlencode 'resource=https://mcp.example.com'

The redirect_uri, client_id, code_verifier, and resource must correspond to the authorization request. Send the returned access token only to the resource named in aud.

The example CIMD declares refresh_token in grant_types because the client can safely store refresh tokens. In current Signet, actual issuance is controlled by ENABLE_REFRESH_TOKENS; offline_access is accepted as a user-safe scope but is neither advertised by Signet nor required for Signet to issue a refresh token. MCP clients should request offline_access only when authorization-server metadata advertises it, and must never assume a refresh token will be returned.

3.4 Understand the current scope constraint

Signet currently limits CIMD clients to this user-safe set:

email profile openid offline_access

If the document omits scope, the whole set is available subject to user consent. If it declares scopes, Signet keeps only the intersection with this set. Unknown or custom scopes such as mcp:tools, read, or write are dropped.

Therefore, do not publish custom MCP scopes in PRM and assume they will work with a CIMD client in the current release. If the MCP server requires custom scopes, use a pre-registered client with an administrator-approved scope policy until Signet explicitly supports custom CIMD scopes.

Part 4: CIDR and SSRF Security for Metadata Fetching

This section matters to authorization-server owners and anyone implementing a CIMD fetcher. A client owner only needs to ensure that every DNS address for the metadata hostname is publicly reachable.

4.1 CIDR in one minute

CIDR writes an IP prefix as address/prefix-length. The number after / is the count of fixed leading bits:

  • 0.0.0.0/8 covers 0.0.0.0 through 0.255.255.255; blocking only 0.0.0.0 is not equivalent.
  • 10.0.0.0/8 covers the RFC 1918 range whose first octet is 10.
  • 100.64.0.0/10 covers 100.64.0.0 through 100.127.255.255.
  • ::1/128 is one IPv6 address; fc00::/7 is the IPv6 unique-local range.

Use an IP-address library such as Go’s net/netip. Never implement CIDR matching with a string-prefix comparison.

prefix := netip.MustParsePrefix("100.64.0.0/10").Masked()
blocked := prefix.Contains(netip.MustParseAddr("100.100.100.200")) // true

Always call Addr.Unmap() before applying IPv4 rules so an IPv4-mapped IPv6 form such as ::ffff:127.0.0.1 cannot bypass the loopback check.

4.2 Why HTTPS URL validation is insufficient

The authorization endpoint accepts a user-influenced client_id. Without a network guard, an attacker can make the authorization server send HTTPS requests to:

  • loopback administration services;
  • RFC 1918 or IPv6 ULA private services;
  • Kubernetes, CGNAT, or cloud instance-metadata networks;
  • link-local addresses;
  • NAT64 or 6to4 addresses that reach an embedded IPv4 target; or
  • internal TLS services exposed by split-horizon DNS.

A hostname can also return a public address during an early safety check and a private address when the HTTP client resolves it again. This DNS rebinding race is why a separate LookupIP followed by a normal http.Get is unsafe.

4.3 Signet’s blocked address policy

Signet rejects invalid, loopback, RFC 1918/ULA private, link-local, interface-local multicast, all multicast, unspecified, and IPv4 limited-broadcast addresses. It also rejects these prefixes that require explicit checks:

Prefix Reason
0.0.0.0/8 “This network” range that may be routed locally
100.64.0.0/10 Shared/CGNAT space; also used by some infrastructure metadata services
192.0.0.0/24 IETF protocol assignments
198.18.0.0/15 Network benchmarking space sometimes routed internally
240.0.0.0/4 Reserved/future-use IPv4 space
2002::/16 6to4, which embeds an IPv4 destination
64:ff9b::/96 NAT64 well-known prefix

This is an application denylist, not a promise to classify every IANA special-purpose or provider-specific address. Keep a network egress firewall as a second layer, and periodically compare policy with the IANA special-purpose address registries.

4.4 Enforce the decision at connection time

The safe point is after DNS resolution and immediately before connect(2). Go’s net.Dialer.Control receives the concrete candidate IP selected by the dialer, so every A or AAAA candidate can be refused without a check/use race.

The following is a transport-only blueprint, not a complete fetcher. It mirrors Signet’s connection controls; production code must also implement every validation and limit listed immediately after the code, plus suitable metrics, structured logs, tests, and lifecycle cleanup:

package cimd

import (
    "crypto/tls"
    "errors"
    "net"
    "net/http"
    "net/netip"
    "slices"
    "syscall"
    "time"
)

var blockedPrefixes = []netip.Prefix{
    netip.MustParsePrefix("0.0.0.0/8"),
    netip.MustParsePrefix("100.64.0.0/10"),
    netip.MustParsePrefix("192.0.0.0/24"),
    netip.MustParsePrefix("198.18.0.0/15"),
    netip.MustParsePrefix("240.0.0.0/4"),
    netip.MustParsePrefix("2002::/16"),
    netip.MustParsePrefix("64:ff9b::/96"),
}

func disallowedIP(ip netip.Addr) bool {
    ip = ip.Unmap()
    if !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() ||
        ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
        ip.IsInterfaceLocalMulticast() || ip.IsMulticast() ||
        ip.IsUnspecified() ||
        ip == netip.AddrFrom4([4]byte{255, 255, 255, 255}) {
        return true
    }
    return slices.ContainsFunc(blockedPrefixes, func(p netip.Prefix) bool {
        return p.Contains(ip)
    })
}

func guardDial(_, address string, _ syscall.RawConn) error {
    host, _, err := net.SplitHostPort(address)
    if err != nil {
        return errors.New("refused malformed dial address")
    }
    ip, err := netip.ParseAddr(host)
    if err != nil || disallowedIP(ip) {
        return errors.New("non-public destination refused")
    }
    return nil
}

func metadataClient(timeout time.Duration) *http.Client {
    dialer := &net.Dialer{
        Timeout: 10 * time.Second,
        Control: guardDial,
    }
    transport := &http.Transport{
        Proxy:               nil,
        DialContext:         dialer.DialContext,
        TLSHandshakeTimeout: 10 * time.Second,
        TLSClientConfig:     &tls.Config{MinVersion: tls.VersionTLS12},
        ForceAttemptHTTP2:   true,
    }
    return &http.Client{
        Timeout:   timeout,
        Transport: transport,
        CheckRedirect: func(*http.Request, []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
}

Required fetch behavior around that client is:

  1. Require the CIMD URL shape before creating the request.
  2. Send GET with Accept: application/json.
  3. Refuse all redirects, including same-origin redirects.
  4. Accept status 200 only.
  5. Read through io.LimitReader(limit + 1) so an oversized body is detected without trusting Content-Length.
  6. Parse JSON and validate client_id, redirect URIs, token authentication method, and grant types.
  7. Return only a generic error to the OAuth client; keep resolved IP and port details in operator logs to avoid creating a network oracle.
  8. Cache/rate-limit failures so repeated authorization requests cannot turn the server into an outbound request amplifier.
  9. Apply the same guarded client, scheme policy, redirect rule, size limit, and timeout to every URL dereferenced from metadata, such as a future logo_uri, jwks_uri, or sector_identifier_uri.

Signet currently stores client_uri as descriptive text and ignores unknown members; it does not dereference those URLs. If a future implementation fetches them, calling a default http.Get would reopen the SSRF path even though the original client_id fetch was protected.

Do not switch Proxy to http.ProxyFromEnvironment without redesigning this control. With a forward proxy, the dialer usually sees the proxy’s IP rather than the final URL destination. The proxy must resolve the target itself and enforce public-only destinations and safe CONNECT ports, or CIMD should use direct egress. A transparent sidecar, split-horizon DNS, or NAT also requires egress firewall or NetworkPolicy enforcement.

CIMD_ALLOW_PRIVATE_NETWORKS=true bypasses Signet’s address guard. It exists for isolated local tests where a metadata origin runs on loopback. Never enable it in a shared development environment or production.

Part 5: Caching, Updates, and Containment

Signet caches a valid document for:

max(1 minute, min(CIMD_CACHE_TTL, response Cache-Control max-age))

When max-age is absent or malformed, CIMD_CACHE_TTL is used. Current Signet parses only max-age; no-cache and no-store do not disable this application cache. Each Signet process has its own in-memory CIMD cache, so replicas can temporarily hold different entries and a process restart clears that replica’s entries.

Signet also caches a failed fetch or invalid document for one minute to limit repeated outbound requests. This is a known Signet deviation: both CIMD draft-00 and the latest draft say error responses and invalid documents must not be cached. Operators should account for this anti-amplification tradeoff rather than assuming full draft conformance.

Consequences for operators:

  • A metadata edit is not an instant revocation mechanism. During a rollout, inspect every replica because one process may have refreshed while another still has an older positive or negative entry.
  • A successful refresh updates document-controlled fields such as name, URI, redirect URIs, and scopes.
  • Refresh does not overwrite the shadow client’s administrator-controlled status. Setting one CIMD row to inactive is a durable authorization containment switch, including during a concurrent refresh. However, authorization currently resolves/fetches the document before checking status, so inactive does not stop outbound metadata traffic.
  • CIMD_ENABLED is startup configuration, not a hot-reload switch. Setting it to false takes effect only after every Signet replica is restarted or redeployed. It stops CIMD resolution and new authorization at /oauth/authorize, but does not by itself invalidate an already issued authorization code or refresh token.
  • Deleting a shadow client while CIMD remains enabled is not a durable block: the next authorization request can fetch the document and recreate an active row.
  • Database deletion/revocation cannot instantly invalidate an access JWT at an MCP server that verifies it offline. That server continues accepting the JWT until exp unless it also enforces online status or its own denylist.
  • Regular clients keep their UUID client_id in admin URLs. CIMD clients use their numeric DB id (/admin/clients/<id>) because a URL-shaped client_id cannot travel in one path segment; both route-reference forms are accepted.

Recommended response sequence for an incident:

  1. Set the affected shadow client to inactive to block new authorization and subsequent token operations for that client. Keep the inactive row so a CIMD request cannot recreate it as active.
  2. Revoke that client’s token records through the administrative workflow. For already issued access JWTs, also use the MCP server’s denylist/status mechanism or wait for their short expiry.
  3. To stop outbound fetches for one URL, block that destination in the egress proxy/network policy; inactive status alone is insufficient.
  4. For a global shutdown, deploy CIMD_ENABLED=false to every replica, restart them, and verify the capability flag is absent. Then revoke/delete shadow clients if required; deleting while the feature is still enabled permits automatic recreation.
  5. Fix the origin document, DNS, TLS, or policy issue, test from the same DNS and egress environment as Signet, and account for each replica’s cache before re-enabling.

Verification Checklist

Client metadata owner

  • [ ] The canonical HTTPS URL is stable and returns a direct 200 with no redirect or authentication.
  • [ ] client_id is byte-exactly equal to that URL.
  • [ ] The body is valid JSON, preferably below 5 KB and always below Signet’s 64 KiB cap.
  • [ ] There are one to ten exact redirect URIs.
  • [ ] token_endpoint_auth_method is none and no secret appears anywhere.
  • [ ] Every A and AAAA answer is publicly routable and the TLS chain is valid.
  • [ ] Cache headers match the desired update interval.

MCP client implementation

  • [ ] PRM resource exactly matches the MCP server the user intended to contact.
  • [ ] The selected authorization server passes local trust policy and its metadata issuer is an exact match.
  • [ ] The validated issuer, resource, state, and PKCE verifier are bound to one flow record.
  • [ ] Callback state and RFC 9207 iss are validated before any code exchange or error display.
  • [ ] Refresh tokens are stored confidentially and the client tolerates their absence.

MCP server owner

  • [ ] PRM returns the exact resource identifier and trusted Signet issuer.
  • [ ] Missing/invalid credentials receive 401 with the resource_metadata challenge.
  • [ ] Browser clients can read WWW-Authenticate when cross-origin access is required.
  • [ ] Tokens are verified for signature, algorithm, issuer, time, type=access, exact audience, and required permissions.
  • [ ] Refresh tokens, ID tokens, and access tokens for another resource are rejected.

Signet operator

  • [ ] CIMD_ENABLED=true is an explicit risk decision.
  • [ ] CIMD_ALLOWED_RESOURCES contains exact, minimal resource identifiers.
  • [ ] CIMD_ALLOW_PRIVATE_NETWORKS=false in production.
  • [ ] RS256/ES256 signing keys and a 32-byte-or-longer random SESSION_SECRET come from a secret manager.
  • [ ] Direct outbound HTTPS, DNS, proxy/sidecar behavior, and egress firewall policy are tested.
  • [ ] Fetch latency, failures, unique CIMD URL volume, and component=cimd warnings are monitored.
  • [ ] Administrators know how to inactivate or delete a CIMD shadow client.

End-to-End Smoke Test

Run this after deployment and after DNS, proxy, key, or CIMD policy changes:

set -euo pipefail

RESOURCE='https://mcp.example.com'
ISSUER='https://auth.example.com'
CLIENT_ID='https://client.example.com/oauth/client.json'
REDIRECT_URI='https://client.example.com/oauth/callback'
PRM_URL='https://mcp.example.com/.well-known/oauth-protected-resource'
AS_METADATA_URL='https://auth.example.com/.well-known/oauth-authorization-server'
AUTHORIZATION_ENDPOINT='https://auth.example.com/oauth/authorize'
TOKEN_ENDPOINT='https://auth.example.com/oauth/token'
JWKS_URI='https://auth.example.com/.well-known/jwks.json'
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

fetch_200() {
  local url="$1"
  local output="$2"
  local status
  status="$(curl --proto '=https' --noproxy '*' --silent --show-error --max-redirs 0 \
    --connect-timeout 5 --max-time 10 --max-filesize 65536 \
    --output "$output" --write-out '%{http_code}' "$url")"
  test "$status" = '200'
  test "$(wc -c < "$output")" -le 65536
}

fetch_200 "$CLIENT_ID" "$tmpdir/cimd.json"
fetch_200 "$PRM_URL" "$tmpdir/prm.json"
fetch_200 "$AS_METADATA_URL" "$tmpdir/as.json"

jq -e --arg id "$CLIENT_ID" --arg redirect "$REDIRECT_URI" \
  '.client_id == $id and
   (.redirect_uris | type == "array" and length >= 1 and length <= 10 and
     index($redirect) != null and all(.[]; type == "string")) and
   (.token_endpoint_auth_method == "none") and
   (.grant_types | type == "array" and index("authorization_code") != null)' \
  "$tmpdir/cimd.json"
jq -e --arg r "$RESOURCE" --arg i "$ISSUER" \
  '.resource == $r and
   (.authorization_servers | type == "array" and . == [$i]) and
   (.bearer_methods_supported | type == "array" and index("header") != null)' \
  "$tmpdir/prm.json"
jq -e --arg i "$ISSUER" --arg auth "$AUTHORIZATION_ENDPOINT" \
  --arg token "$TOKEN_ENDPOINT" --arg jwks "$JWKS_URI" \
  '.issuer == $i and
   .authorization_endpoint == $auth and
   .token_endpoint == $token and
   .jwks_uri == $jwks and
   .authorization_response_iss_parameter_supported == true and
   .client_id_metadata_document_supported == true' \
  "$tmpdir/as.json"

Use this preflight only with fixed, operator-reviewed deployment values. Run it in a low-privilege diagnostic job with the same DNS view and an egress firewall that already blocks non-public destinations; never feed a request-supplied URL into it. Curl does not reproduce Signet’s dial-time IP guard, so this checks direct HTTP status, bounds, and metadata shape—not SSRF policy. A real flow through Signet remains mandatory. The script intentionally does not automate the user’s login/consent or pretend that decoding a JWT verifies it.

  1. From the restricted diagnostic environment, inspect every A and AAAA answer, then fetch the fixed CIMD URL with redirects disabled. Confirm direct 200, public addresses, trusted TLS, size, and exact client_id. Do not run an unguarded curl against a URL supplied by an authorization request.
  2. Fetch the MCP server’s PRM. Confirm its resource and authorization_servers values exactly match the deployment plan.
  3. Fetch Signet authorization-server metadata. Confirm exact issuer, expected endpoints, authorization_response_iss_parameter_supported: true, and client_id_metadata_document_supported: true.
  4. Run a real authorization-code + S256 PKCE flow. Validate callback state and iss, exchange the code with the same resource, and never log the code, verifier, or tokens.
  5. Verify the access JWT cryptographically and confirm type=access plus exact iss and aud. Decoding without signature verification is useful only for inspection, never acceptance.
  6. Call the MCP server without a token and expect the PRM-aware 401; call with the valid token and expect success; exercise one insufficient-permission path and expect a 403 challenge.
  7. For browser clients, run the same flow in the browser and confirm CORS exposes WWW-Authenticate on both 401 and 403 responses.

Troubleshooting

Symptom Likely cause Check
unauthorized_client CIMD is disabled, URL shape is not accepted, or the shadow client is inactive Capability metadata, CIMD_ENABLED, HTTPS host/path, admin status
invalid_client during authorization Fetch failed or document validation failed Signet component=cimd logs, direct 200, TLS, size, JSON, exact client_id, redirect list
invalid_target Resource is absent from the global allowlist or differs byte-for-byte PRM resource, request value, and CIMD_ALLOWED_RESOURCES, including trailing slash
invalid_scope or missing custom permission Current CIMD scope restriction removed the custom scope Use only the user-safe set or a pre-registered client
An inactive client still produces fetch logs Status is checked after document resolution; inactive blocks authorization, not egress Keep it inactive and add a destination egress block if fetches must stop
A document edit seems ignored or differs by pod A per-process positive/negative cache entry is still fresh Every replica’s logs, Cache-Control max-age, CIMD_CACHE_TTL, one-minute floor; restart clears only that process
Works on loopback but not in production All usable answers are non-public/special-use, answers differ by DNS view, or TLS is untrusted All A/AAAA answers from a Signet pod, split-horizon DNS, certificate chain, SSRF logs
Refresh still works after global CIMD shutdown The flag gates authorization-time resolution, not previously issued credentials Inactivate/revoke the shadow client and apply access-JWT status policy at the MCP server
Browser cannot discover the challenge CORS hides WWW-Authenticate MCP server’s Access-Control-Expose-Headers and allowed origin
Token is valid but MCP returns 401 Issuer, algorithm, type, or audience validation failed Decoded claims, JWKS selection, exact resource ID; do not log the raw token

References