Key-Pinned Upstreams
New to this? Pin an MCP server by its key is the concept behind this page, and Stop a rescheduled pod from answering as your MCP server walks through it on one machine.
A remote MCP server can be pinned by its TLS key (ADR-030). Hangar then accepts a connection to it only when the peer presents one of the listed keys, and it checks this inside the TLS handshake, before the first request byte. This page is the operator's reference: where a key list is declared, what each place needs, and how to rotate, observe and upgrade. Core supports it from 2.32.0, the operator from 0.17.32.
The key list
A peer identity is a key_ref, which names the key lineage and survives rotation, and a list of key pins:
mcp_servers:
payments:
mode: remote
endpoint: https://payments.payments.svc:8443/mcp
tls:
peer_identity:
key_ref: payments-tls
spki_sha256:
- sha256:06eeb57619607484302cf6ffa9ff03c37dd97cf0a5582b3712acd5a1a84e3901
max_connection_age: 1h
-
A pin is
sha256:followed by 64 lowercase hex characters: the SHA-256 of the certificate's DERSubjectPublicKeyInfo, as the certificate encodes it. Read it from the certificate the upstream serves:echo "sha256:$(openssl x509 -in server.crt -pubkey -noout \ | openssl pkey -pubin -outform der | openssl dgst -sha256 -hex | awk '{print $NF}')"From 2.32.1 this matches for an EC key written with explicit curve parameters too (mcp-hangar#1982); 2.32.0 refused one. Prefer named curves anyway: PKIX forbids explicit parameters (RFC 5480), and with the
opensslmacOS ships that takes-pkeyopt ec_param_enc:named_curve. -
The endpoint must be
https. The key replaces the certificate chain and the hostname as the identity: Hangar requires TLS 1.3 and consults no CA, so a self-signed certificate is fine. -
max_connection_age(seconds, or90s,30m,1h; default1h) bounds how long one connection pool serves. When it expires, the pool is replaced and every new connection handshakes against the current list. It is refused on a server without a peer identity. -
issuanceis not accepted by core yet. ADR-030 namesissuance: kbs-attested, a key a Key Broker Service released only to an attested guest. Core refuses the key at load, rather than accepting a declaration it does not act on. See Attested keys.
Where a key list is declared
The configuration file
A server the file declares carries its key list as above. The file is the operator-trusted source of the servers it declares, so no API permission is involved. A replica reloading the file applies a changed list.
A release before 2.32.0 ignores tls.peer_identity and serves such a server as a plain one, without checking its key. Do not ship the file to a replica older than 2.32.0.
The REST API
POST /api/mcp_servers with a tls block creates a key-pinned server, and PUT /api/mcp_servers/{id} changes its list or endpoint. A key-pinned server registered over the API needs:
-
Durable storage.
persistence.backendmust besqlite(one gateway) orpostgresql(several replicas). The server's name andkey_refare recorded there, in the peer-key name record, and that is how replicas learn of a changed list. Without durable storage the declaration is refused. -
The permission. Setting or changing a peer identity, changing a key-pinned server's endpoint, deleting it, and forgetting its name (
DELETE /api/mcp_servers/{id}/peer_identity) all needmcp_servers:peer_identity, on top of what the route already needs (mcp_servers:lifecyclefor a delete). Of the built-in roles, onlyadminandkubernetes-operatorhold it; a custom role can be granted it.mcp_servers:writeis not enough. Under OPA, allow actionpeer_identityon resource typemcp_servers. -
Authentication. With authentication disabled, any client of the API can set or change a key list. A key list decides which upstream a call may reach, so run authentication wherever lists are set over the API.
-
The SSRF policy. An endpoint set over the API is checked against it. A key-pinned server is usually reached at a private address, so list the networks it may resolve into:
ssrf: trusted_networks: - "10.96.0.0/12" # Services, for a remote server addressed by a .svc name - "10.244.0.0/16" # pods, for a mode: container server reached at its pod IPOtherwise every such endpoint is refused with
ssrf_blocked. Loopback can never be trusted this way.
A name keeps its key after its server is deleted, so it cannot come back as a plain server. A create under a recorded name succeeds only with that key_ref and the permission. Forgetting the name is a separate act, after the delete.
The Kubernetes operator
The MCPServer CRD carries the same declaration:
apiVersion: mcp-hangar.io/v1alpha2
kind: MCPServer
metadata:
name: payments
spec:
mode: remote
endpoint: https://payments.payments.svc:8443/mcp
tls:
peerIdentity:
keyRef: payments-tls
spkiSha256:
- sha256:06eeb57619607484302cf6ffa9ff03c37dd97cf0a5582b3712acd5a1a84e3901
maxConnectionAge: 1h
The apiserver refuses a malformed pin, a missing keyRef and an http endpoint. For mode: container, the operator runs the pod and registers it at https://<pod IP>:8080. The pod gets no discovery annotations, and it must serve TLS on port 8080 with a listed key.
The operator registers a key-pinned server only when all of these hold. Each failure below is condition Degraded on the CR, with its reason:
| Requirement | Reason when it fails |
|---|---|
Core lists peer_identity in capabilities of GET /api/system/ (core 2.32.0 or newer) | PeerIdentityNotCarried |
hangar.url is https:// | PeerIdentityNotCarried |
No issuance is declared | PeerIdentityNotCarried |
The operator's core API key holds kubernetes-operator | RegistrationForbidden |
| Core has durable storage | RegistrationFailed |
The endpoint is inside ssrf.trusted_networks | EndpointNotTrusted |
Two cases do not appear as Degraded:
- A private CA. When a private CA signed core's certificate, set
hangar.tls.ca.secretNameorhangar.tls.ca.configMapNamein the operator chart (the operator's--hangar-ca-file). Without it the operator cannot read core's capabilities, the reconcile fails and is retried, and the CR gets no condition for it. Read the operator's log. - A
mode: containerserver on an operator with no core to register with (no--hangar-url) is held: its pod is not created, and the CR readsReady=Falsewith reasonPeerIdentityNotCarried.
See KUBERNETES.md for the rest of the MCPServer fields.
Rotating a key
The list is a set, so rotation is two edits:
- Add the next key beside the current one, then roll out the workload with the new key.
- Remove the old key.
A single update cannot replace the whole list under the same key_ref: a list that shares no key with the stored one is refused, so a typo cannot cut every trusted key at once. A new key_ref starts a new lineage, and its list need share nothing with the old one. It also changes the server's identity (below).
Every replica applies a changed list. It replaces its copy of the server with a cold one built from the stored row, and the old copy's connections close. A key removed from the list is trusted until that change reaches the replica.
Identity and digest pins
A key-pinned server's upstream identity is peer-key:<key_ref>, not remote:https://host:port. Digest pins bound to it survive an endpoint change and a rotation within the lineage. Giving an existing server a peer identity, or changing its key_ref, changes its identity, so record its pins again with mcp-hangar pin --write.
Observing it
- A refusal carries
observed_peer_spki, the key the peer presented. A refused cold start is aToolCallRefused, exported as statusdeniedwith reasonupstream_peer_key_mismatch. A refusal on a warm connection is aToolInvocationFailed, exported as statuserrorwithfailure_code: upstream_peer_key_mismatch. - A served call carries
upstream_peer_spki, the verified key of the connection that served it. - A declared issuance. From 2.33.0 a call to a server that declares
issuancecarries it asdeclared_key_issuance, in JSON-lines, LEEF, syslog and OTLP; CEF has no free field for it. It records what the configuration declares, never a verification. - Exports. JSON-lines, LEEF, syslog and OTLP carry each field under its own format's name. CEF carries the one key a record has as
fileHash, withfileTypesaying which (UpstreamTlsKeyVerifiedorUpstreamTlsKeyRefused); the refusal's code is inreason. mcp-hangar whyshows a refused key next to the keys the configuration file lists now.mcp_hangar_upstream_peer_key_connections_total{mcp_server,outcome}counts handshakes,verifiedorrefused, the GET stream's included. From 2.32.1mcp_serveris the server's id. 2.32.0 labelled it with the endpoint's host.
Several replicas, and upgrades
Declare a key-pinned server over the API, or from the operator, only once every replica runs 2.32.0 or newer. A release before 2.32.0 does not read the row, so it does not serve the server. Its create, though, knows nothing of the name record.
- From 2.32.1 the database refuses that create. The first 2.32.1 replica to start installs a trigger on the configuration table, which refuses a live row under a recorded name that does not carry the recorded
key_ref(mcp-hangar#1987). - On 2.32.0 alone nothing does, and the older replica can rewrite the row as a plain server and serve it unpinned.
From 2.32.1, too, the built-in roles in force are the running release's own. A 2.31.0 replica starting on a shared auth store no longer takes mcp_servers:peer_identity away from kubernetes-operator on the 2.32.1 replicas (mcp-hangar#1988). A 2.32.0 replica still reads the stored role, so it can still lose it. The two-replica cases run nightly in the live tier, against PostgreSQL and with the published 2.31.0 beside the current release.
Attested keys
issuance: kbs-attested declares that a Key Broker Service released each listed key only to a guest on appraised TEE hardware whose init-data hash was the one bound to that key. That is code identity, not instance identity: any guest launched with the same init-data on accepted hardware receives the key (ADR-031). Hangar records the declaration and verifies none of it.
What each release supports.
- Core 2.33.0 accepts
issuance, records it on each call asdeclared_key_issuance, and lists thedeclared_key_issuancecapability. Core 2.32.1 and older refuse it at load. - Operators before 0.17.34 accept
issuancein the CRD and register no server that declares it. - Operator 0.17.34 carries
spec.initDatainto the pod and holds akbs-attestedserver to its rules (see KUBERNETES.md). It registers such a server only with a core that lists thedeclared_key_issuancecapability, 2.33.0 or newer.
What follows is the provisioner's part, which Hangar cannot see or check. Every item is a condition of the declaration being true.
-
The key. Generate the key pair yourself and upload the private key as a KBS resource (
POST /kbs/v0/resource/<repo>/<type>/<tag>). Keep no copy, and list the SPKI of the public half withkey_refnaming the resource path. A KBS resource cannot be read back by an admin, so there is no other source of the SPKI. Never take it from the running pod. -
The resource policy. Release the resource only when all three hold:
- the evidence is from a TEE, not Trustee's sample evidence;
- the guest TCB has an affirming appraisal against your reference values;
- the init-data hash equals the hash of the whole document you bound to the key.
A rule over the parsed init-data claims does not qualify: Trustee drops the agent policy's rules from them.
-
The agent policy, carried in the init-data. Generate it with Kata's
genpolicyfrom the exact Deployment the pod is rendered with. Get that Deployment from the operator image of the release you run:kubectl get mcpserver NAME -o yaml | docker run --rm -i ghcr.io/mcp-hangar/mcp-hangar-operator:<version> render. Feed it the stored object, because the pod template carries its generation. A change to the operator's rendering ships with an upgrade note, and means generating the policy again. Keep its defaults, which refuseExecProcess,ReadStream,WriteStreamandSetPolicy, and keepAllowRequestsFailingPolicy := false. RefusingReadStreamalso stopskubectl logs, so logs leave by the workload's own channel.The policy goes into the init-data, so it exists before the CR does. Render the manifest you are about to apply with
metadata.generation: 1, which is what a fresh object is stored with. With kata 4.2.0'sgenpolicy, measured end to end in the operator's T26 run:- It refuses four fields the operator renders for a kbs-attested pod:
automountServiceAccountToken,runAsNonRooton the pod and on the container, and the pod'sseccompProfile. Remove each from the rendered Deployment only where it holds the safe value (false,true,RuntimeDefault). The policy pins the UID and GID itself, and the container keeps its ownseccompProfile. Do not pass-s, which silences every fieldgenpolicydoes not model. - With the image pulled in the guest,
genpolicycannot read the user from the image. SetrunAsUserandrunAsGroupinspec.podSecurityContext. - Its default settings expect OCI 1.1.0. A current containerd sends 1.3.0, and the guest then refuses the sandbox. Add the matching
drop-in-examples/20-oci-*file togenpolicy-settings.d, and pass the settings directory with-j.
- It refuses four fields the operator renders for a kbs-attested pod:
-
The init-data. Name the
key_refin it, so each key lineage has its own hash, and put no secret in it: it is public on the CR and on the pod. The hash covers the raw bytes, so a YAML|versus|-changes it. Bind the hash the operator reports instatus.initDataDigests(sha256for SNP,sha384for TDX), not one computed from your source file. -
Rotation.
- List the next key beside the current one.
- Overwrite the resource.
- Roll the pod.
- Remove the old key.
Nobody keeps the overwritten key, so a bad next key is fixed by rotating forward.
What a key pin does not establish
A key pin proves that the connection ended at a listed key. It does not prove how the key came to be listed:
- whoever holds
mcp_servers:peer_identity, or writes the configuration file, can list a key they control; - a stolen key that is on the list is trusted until it is removed.