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:
- How the connection works
- Prerequisites
- Start the setup
- Enter the cluster
- Choose the workload
- Give the pod a Speakeasy token
- Exchange the token and call a server
- Test from the pod
- Manage and revoke access
How the connection works
Section titled “How the connection works”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.
Prerequisites
Section titled “Prerequisites”- A GKE cluster, and
kubectlaccess 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.
Start the setup
Section titled “Start the setup”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.
Enter the cluster
Section titled “Enter the cluster”To list the clusters in a project with their locations, run:
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:
kubectl get --raw /.well-known/openid-configuration | jq -r .issuerThe output must match the issuer the step shows exactly, in this form:
https://container.googleapis.com/v1/projects/PROJECT_ID/locations/LOCATION/clusters/CLUSTER_NAMEIf it doesn’t match, check the project, location, and cluster name. Select Continue.
Choose the workload
Section titled “Choose the workload”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_ACCOUNTThe 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.
Give the pod a Speakeasy token
Section titled “Give the pod a Speakeasy token”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/v1kind: Deploymentmetadata: name: my-workload namespace: NAMESPACEspec: 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: 600SPEAKEASY_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.
Exchange the token and call a server
Section titled “Exchange the token and call a server”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:
TOKEN_ENDPOINT=https://AUTHENTICATION_HOST/oauth/usi/ISSUER_ID/tokenMCP_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.
Test from the pod
Section titled “Test from the pod”Run the exchange from a pod of the workload:
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
kubectlreports. - The token hasn’t been exchanged already. Wait for the kubelet to write a new one, or lower
expirationSecondsto 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.
Manage and revoke access
Section titled “Manage and revoke access”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.
Next steps
Section titled “Next steps”- Read Workload identity for how trusted platforms, access rules, and token exchange work.
- Read Authorizing agents to scope the agent the workload acts as.