Skip to content
Status

Identity and MCP governance / Connect GKE workloads with workload identity

Connect GKE workloads with workload identity

Let pods on a Google Kubernetes Engine cluster call MCP servers as an agent, using the service account token Kubernetes already issues, with no Speakeasy secret stored in the cluster.

Every pod on a Google Kubernetes Engine (GKE) cluster runs as a Kubernetes service account, and the cluster can issue the pod a short-lived token that proves it. With workload identity, the pod exchanges that token for a Speakeasy access token and calls MCP servers as a Speakeasy agent. Nothing secret is stored in the cluster, and nothing needs rotating by hand.

This guide covers:

When a pod needs to call an MCP server:

  • The kubelet writes a token for the pod’s service account to a file in the pod, issued for Speakeasy’s audience and signed by the cluster. Its subject has the form system:serviceaccount:NAMESPACE:SERVICE_ACCOUNT.
  • The pod posts the token to the Speakeasy token endpoint.
  • Speakeasy checks the signature against the cluster’s published keys and matches the subject against the access rule saved for the agent.
  • Speakeasy returns an access token valid for 15 minutes. The pod sends it on MCP requests and exchanges a new token when it expires.

Each GKE cluster signs its own tokens, so each connected cluster is trusted separately, and a rule on one cluster admits nothing on another. The rule names one namespace and service account, so only pods running as that service account act as the agent, and they can do only what the agent’s policy allows.

  • A GKE cluster, and kubectl access to it.
  • Permission to edit the workload’s manifests.
  • In Speakeasy: workload:write, which the Admin role holds by default.
  • MCP servers attached to a Speakeasy authorization server in shared mode for the organization or project. If the setup reports that no token endpoint is available, contact Speakeasy to enable one.

GKE publishes every cluster’s signing keys on container.googleapis.com, including clusters with a private control plane, so Speakeasy can verify their tokens without network access to the cluster.

Open Identity > Agents in the project sidebar and select New agent identity. Name the agent, select Continue, then choose the MCP servers the workload may call. The agent reaches nothing else, and never more than the person creating it can reach.

On the credential step, choose Workload identity, then choose GKE under Platform and select Continue.

To list the clusters in a project with their locations, run:

Terminal window
gcloud container clusters list --format="table(name,location)"

Enter the cluster’s Project ID, Location, and Cluster name. The location is the cluster’s region, such as us-central1, or its zone, such as us-central1-a.

Once all three are valid, the step shows the issuer Speakeasy will trust. To confirm it, point kubectl at the cluster and run:

Terminal window
kubectl get --raw /.well-known/openid-configuration | jq -r .issuer

The output must match the issuer the step shows exactly, in this form:

https://container.googleapis.com/v1/projects/PROJECT_ID/locations/LOCATION/clusters/CLUSTER_NAME

If it doesn’t match, check the project, location, and cluster name. Select Continue.

Enter the Namespace the workload’s pods run in and the Service account they run as, which is the serviceAccountName in the pod spec. Pods that don’t set one run as default.

Select Connect GKE. Speakeasy creates the agent, trusts the cluster, and saves one access rule:

system:serviceaccount:NAMESPACE:SERVICE_ACCOUNT

The rule is exact. A pod running as any other service account, or in any other namespace, is refused. To let a second workload act as the same agent, add a rule for its own service account.

Give each workload its own service account

Every pod running as the service account acts as the agent, including pods deployed later by anyone who can create pods in that namespace. Use a dedicated service account for the workload rather than default, and choose an agent whose policy reaches only what that workload needs.

Add a projected service account token to the workload’s pod spec, issued for Speakeasy’s audience. The setup shows this manifest with the namespace, service account, and audience filled in. For a Deployment, the same fields go under spec.template.spec:

apiVersion: apps/v1
kind: Deployment
metadata:
name: my-workload
namespace: NAMESPACE
spec:
selector:
matchLabels:
app: my-workload
template:
metadata:
labels:
app: my-workload
spec:
serviceAccountName: SERVICE_ACCOUNT
containers:
- name: app
image: my-image
env:
- name: SPEAKEASY_TOKEN_FILE
value: /var/run/secrets/speakeasy/token
volumeMounts:
- name: speakeasy-token
mountPath: /var/run/secrets/speakeasy
readOnly: true
volumes:
- name: speakeasy-token
projected:
sources:
- serviceAccountToken:
path: token
audience: SPEAKEASY_AUDIENCE
expirationSeconds: 600

SPEAKEASY_AUDIENCE is the issuer URL of the Speakeasy authorization server, shown in the setup. Speakeasy refuses a token issued for any other audience.

The token sits in its own directory, so it doesn’t replace the service account token Kubernetes mounts by default, and it works beside GKE Workload Identity Federation for Google Cloud APIs. Both tokens come from the same service account, but each is issued for its own audience.

Keep the token lifetime at 600 seconds

Speakeasy accepts each token only once, and refuses a token valid for more than an hour. The kubelet writes a new token after about 80% of its lifetime, so at 600 seconds a fresh token is ready roughly every 8 minutes, well before the 15-minute access token runs out. A longer lifetime leaves the pod holding a token it has already used.

Before calling an MCP server, the workload reads the token file and posts it to the token endpoint shown in the setup, using the OAuth JWT bearer grant. No client ID or secret is involved:

Terminal window
TOKEN_ENDPOINT=https://AUTHENTICATION_HOST/oauth/usi/ISSUER_ID/token
MCP_URL=https://MCP_HOST/mcp/SERVER_SLUG
response=$(curl -sS "$TOKEN_ENDPOINT" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "assertion=$(cat "$SPEAKEASY_TOKEN_FILE")")
access_token=$(printf '%s' "$response" | jq -r '.access_token // empty')
if [ -z "$access_token" ]; then echo "Exchange failed: $response" >&2; exit 1; fi
curl -sS --fail-with-body "$MCP_URL" \
-H "Authorization: Bearer $access_token" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

If the exchange is refused, the script prints the token endpoint’s response, such as invalid_grant, and stops before calling the server. MCP_URL is the URL on the MCP server’s page in Speakeasy. The access token works at every MCP server of the authorization server that the agent may connect to.

In an application, keep the access token and reuse it until it expires, then read the file again and exchange the new token. Read the file each time rather than caching its contents, because the kubelet replaces the token in place. Exchange once per access token, from one place in the pod: two containers or processes exchanging the same token file at once means one of them is refused.

Run the exchange from a pod of the workload:

Terminal window
kubectl exec -n NAMESPACE deploy/my-workload -- sh -c 'test -s "$SPEAKEASY_TOKEN_FILE" && echo "token present"'

This confirms the kubelet wrote the token. Then run the exchange from the previous section in the pod. The tools/list request returns the tools the agent’s policy allows.

Each pod that signs in appears on MCP Sessions under the cluster’s issuer and the service account’s subject.

If the exchange fails with invalid_grant, check:

  • The token’s audience matches the Speakeasy issuer URL exactly, including the scheme and any trailing path.
  • The pod runs in the namespace and as the service account in the access rule.
  • The project, location, and cluster name match the issuer kubectl reports.
  • The token hasn’t been exchanged already. Wait for the kubelet to write a new one, or lower expirationSeconds to 600.
  • The agent’s policy allows the server, and the agent owner still has access to it.

Speakeasy returns the same invalid_grant error for every refusal, so these checks are the quickest way to find the cause.

The agent’s Credentials section lists the platforms that sign in as it, under Federated access. Each connected cluster appears by name, such as GKE my-cluster (my-project, us-central1).

  • To change the servers the workload can reach, select Edit on the agent and save a new selection.
  • To connect another cluster, run the setup again for that cluster. Each cluster is trusted separately.
  • To disconnect a cluster, select Remove on its row, then remove the projected token from the workload’s manifest.

Removing access stops new exchanges but doesn’t end sessions already issued, which last up to 15 minutes. To cut off a workload immediately, also revoke its sessions on MCP Sessions, or revoke the agent. Every change is recorded in Audit Logs.