Identity and MCP governance / Connect any OIDC platform with workload identity
Connect any OIDC platform with workload identity
Trust any platform that signs OIDC tokens, and let its workloads call MCP servers as an agent with no stored secret. The worked example is a Vercel Function using the Vercel OIDC token that Vercel Connect also authenticates with.
Any platform that signs OIDC tokens for its workloads can sign in with workload identity, including platforms Speakeasy has no setup card for. Trust the platform as a Custom OIDC issuer and allow the workload’s subject. The workload can then exchange its own token for a Speakeasy access token and call MCP servers as an agent. Nothing secret is stored on the platform.
This guide uses a Vercel Function as the worked example. Vercel signs an OIDC token for every deployment, and Vercel Connect uses the same token to authenticate a deployment before it hands out provider tokens. Speakeasy verifies that token itself, so the function needs no Connect connector. On another platform, the Speakeasy steps are the same. Only the values listed in Adapt it to another platform change.
This guide covers:
- How the connection works
- Prerequisites
- Start the setup
- Enter the identity provider
- Choose which workloads
- Point it at Speakeasy
- Exchange the token and call a server
- Check it worked
- Troubleshooting
- Adapt it to another platform
- Manage and revoke access
How the connection works
Section titled “How the connection works”When the function needs to call an MCP server:
- The function calls
getVercelOidcToken()from@vercel/oidcwith Speakeasy’s audience. Vercel exchanges the deployment’s OIDC token for one carrying thataud. Its subject has the formowner:TEAM_SLUG:project:PROJECT_NAME:environment:ENVIRONMENT. - The function posts the token to the Speakeasy token endpoint.
- Speakeasy checks the signature against Vercel’s published keys, checks the issuer, audience, and expiry, and matches the subject against the access rule saved for the agent.
- Speakeasy returns an access token valid for 15 minutes. The function sends it on MCP requests. When it expires, the function exchanges a new Vercel token.
The rule names one team, project, and environment. Only that project’s deployments in that environment act as the agent, and they can do only what the agent’s policy allows.
The function calls the Speakeasy token endpoint directly, without a Vercel Connect connector. A Connect jwt-bearer token takes its sub from the calling code. A Speakeasy access rule trusts sub alone, so use the deployment’s own OIDC token, whose subject Vercel sets. The device agent signs in as a workload only from GitHub Actions jobs, so it doesn’t apply here either.
Prerequisites
Section titled “Prerequisites”- A Vercel project, and permission to deploy it to production. The example is a Next.js App Router route handler, like Vercel’s own OIDC examples.
@vercel/oidc3.7.0 or later, which added theaudienceandjtioptions.- The project’s OIDC issuer mode set to Team. It’s under the project’s Settings > Security, in Secure backend access with OIDC federation. See Enter the identity provider for why.
- In Speakeasy:
workload:write, which the Admin role holds by default. - A Speakeasy authorization server in shared mode for the organization or project, with every MCP server the function calls attached to it. Check each server’s User session issuer under Settings > Authentication. See Exchange a platform token. If the setup reports that no token endpoint is available, contact Speakeasy to enable one.
Start the setup
Section titled “Start the setup”Trust and access rules are set up on the agent the workload acts as. Edit can’t add them to an existing agent, so create a new agent for the function.
Open Identity > Agents in the project sidebar and select New agent identity. Name the agent and select Continue.
On MCP Selection, select the MCP servers the function may call. Each selected server grants mcp:connect on it. The agent reaches nothing else, and never more than the person creating it can reach. Servers that authorize each person separately are listed under Unavailable servers, because an agent has no person behind it. See Select servers.
Select Continue through MCP sign-in. On Agent sign-in, expand Advanced: trust another platform’s tokens, choose Custom OIDC issuer, and select Continue.
Enter the identity provider
Section titled “Enter the identity provider”On Identity provider, fill in:
| Field | Value for Vercel |
|---|---|
| Name | What the platform is called in Speakeasy, such as Vercel. It isn’t used for matching. |
| Issuer URL | https://oidc.vercel.com/TEAM_SLUG |
| JWKS URL | https://oidc.vercel.com/TEAM_SLUG/.well-known/jwks |
TEAM_SLUG is the team’s identifier from its Vercel URL. Speakeasy stores the issuer URL as entered and compares it with each token’s iss, so confirm it against the issuer’s discovery document:
curl -s https://oidc.vercel.com/TEAM_SLUG/.well-known/openid-configuration | jq '{issuer, jwks_uri}'The output’s issuer must match the Issuer URL exactly, and its jwks_uri the JWKS URL.
Use the Team issuer mode, which Vercel recommends. In Team mode the issuer URL belongs to the team, so the trusted issuer covers only that team’s tokens. In Global mode, every Vercel team’s tokens carry the same issuer, https://oidc.vercel.com. The access rule would then be the only thing separating the team’s deployments from every other Vercel customer’s.
The issuer mode is a project setting, and the issuer is fixed once Speakeasy trusts it. If the project switches to Global, its tokens carry https://oidc.vercel.com, and Speakeasy refuses them.
Choose which workloads
Section titled “Choose which workloads”On Which workloads, fill in:
- Subject:
owner:TEAM_SLUG:project:PROJECT_NAME:environment:production, with the project’s name from its settings. - Match: This subject only.
This rule admits the project’s production deployments and nothing else. Wider rules admit more:
| Subject | Match | Admits |
|---|---|---|
owner:TEAM_SLUG:project:PROJECT_NAME:environment:production | This subject only | The project’s production deployments. Recommended. |
owner:TEAM_SLUG:project:PROJECT_NAME:environment: | Any subject starting with it | Every environment of the project: production, preview, development, and any Custom Environment. |
owner:TEAM_SLUG: | Any subject starting with it | Every project in the team, including projects created later. |
For a prefix, leave the * out of the subject, because choosing Any subject starting with it adds it. End the prefix on a colon, where Vercel’s separator falls: owner:TEAM_SLUG:project:web without the colon also admits a project named web-staging.
Admit production only
Every push to a branch other than the production branch, and every pull request, creates a preview deployment. Anyone who runs vercel env pull on the project gets a development token on their own machine. A rule that admits preview or development hands the agent’s access to everyone who can push a branch or pull the project’s environment variables. Production deployments come from the production branch and vercel --prod, so the exact production subject limits the agent to people who can deploy to production.
Speakeasy verifies the token’s signature, issuer, audience, and expiry, then matches sub against the rule. It doesn’t check owner_id, project_id, or environment separately, or Vercel’s own controls, such as who may deploy to production. The subject uses the team slug and project name, not their IDs. Renaming the project changes the subject. Renaming the team changes the subject and the issuer. Either way the rule stops matching, so set the platform up again after a rename.
Select Connect platform. Nothing is created until then. Speakeasy creates the agent, trusts the issuer, and saves the access rule. Trust applies to MCP servers in every project. Trusting the platform and allowing its access are both recorded in the organization audit log.
Point it at Speakeasy
Section titled “Point it at Speakeasy”Point it at Speakeasy shows the values the function needs:
- Token endpoint: where the function exchanges its token, such as
https://AUTHENTICATION_HOST/oauth/usi/ISSUER_ID/token. - Audience: the authorization server’s issuer URL, such as
https://AUTHENTICATION_HOST/oauth/usi/ISSUER_ID. The Vercel token must carry thisaud, and no other. - MCP host: where the function sends the access token.
The MCP server URL is on the server’s page in Speakeasy, such as https://MCP_HOST/mcp/SERVER_SLUG.
Exchange the token and call a server
Section titled “Exchange the token and call a server”The route below gets a Vercel token issued for Speakeasy, exchanges it with the OAuth JWT bearer grant, and lists the MCP server’s tools with the access token. No client ID or secret is involved. Replace the three values with the ones from the previous step:
import { getVercelOidcToken } from "@vercel/oidc";
const TOKEN_ENDPOINT = "https://AUTHENTICATION_HOST/oauth/usi/ISSUER_ID/token";const AUDIENCE = "https://AUTHENTICATION_HOST/oauth/usi/ISSUER_ID";const MCP_SERVER_URL = "https://MCP_HOST/mcp/SERVER_SLUG";
export async function GET() { // A Vercel token issued for Speakeasy. A new jti makes each exchange use a // token Speakeasy hasn't seen before. const vercelToken = await getVercelOidcToken({ audience: AUDIENCE, jti: crypto.randomUUID(), });
const exchange = await fetch(TOKEN_ENDPOINT, { method: "POST", body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: vercelToken, }), }); const result = await exchange.json(); if (!exchange.ok) { // Log the error code only, never a token. console.error("Token exchange failed:", result.error); return Response.json({ error: "token exchange failed" }, { status: 502 }); }
const response = await fetch(MCP_SERVER_URL, { method: "POST", headers: { Authorization: `Bearer ${result.access_token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), }); return new Response(await response.text(), { status: response.status, headers: { "Content-Type": response.headers.get("Content-Type") ?? "text/plain" }, });}How each part works:
audiencemakes Vercel issue the token for Speakeasy. A token without it carrieshttps://vercel.com/TEAM_SLUG, and Speakeasy refuses it.- Speakeasy accepts a token with a
jtionly once, so pass a newjtion every call. - Call
getVercelOidcToken()inside the request handler. Vercel passes the deployment’s token with each request, so the call fails at module level. - Without
resource, the access token works at every MCP server of the authorization server that the agent may connect to. To bind it to one server, addresourcewith the server’s URL, as in Exchange a platform token. - In a function that calls servers often, reuse the access token until
expires_inruns out instead of exchanging on every request.
The route acts as the agent for anyone who calls it
The route answers anyone who can reach the deployment, and every call it makes uses the agent’s access. Use it to test the connection. Then remove it, or check the caller before the route calls a server.
Check it worked
Section titled “Check it worked”Check it worked asks for one exchange from the platform. Deploy the function to production and request the route:
curl https://PRODUCTION_DOMAIN/api/speakeasy-toolsThe response is the MCP server’s answer to tools/list: the tools the agent’s policy allows. The workload’s session appears under the agent’s sessions, and on MCP Sessions under the Vercel issuer and the deployment’s subject.
A production rule refuses local runs and preview deployments. A local run uses the development token from vercel env pull, whose subject ends in environment:development. A preview deployment’s subject ends in environment:preview.
Troubleshooting
Section titled “Troubleshooting”Speakeasy answers every refused exchange with the same invalid_grant, so callers can’t probe the trust policy, and the response never says which check failed. As Check it worked suggests, compare the token’s claims with the values saved for the agent. Log the claims, not the token:
const claims = JSON.parse(Buffer.from(vercelToken.split(".")[1], "base64url").toString());console.log({ iss: claims.iss, aud: claims.aud, sub: claims.sub, exp: claims.exp });Audit Logs records the issuer URL, JWKS URL, and subject exactly as they were saved.
| What the function sees | What to check |
|---|---|
invalid_grant from the token endpoint | aud is the Audience from Point it at Speakeasy, and the token carries no other audience. |
invalid_grant from the token endpoint | iss matches the Issuer URL exactly. A project in the Global issuer mode sends https://oidc.vercel.com. A renamed team sends a new issuer. |
invalid_grant from the token endpoint | sub matches the rule exactly, including the team slug, project name, and environment. A production rule refuses preview deployments and local runs. |
invalid_grant from the token endpoint | exp is no more than an hour away. Speakeasy refuses a token that’s valid for longer. |
invalid_grant from the token endpoint | The token hasn’t been exchanged before. Pass a new jti on every call. |
invalid_grant from the token endpoint | The agent is active, not suspended or revoked, and its owner is still in the organization. |
temporarily_unavailable from the token endpoint | Speakeasy couldn’t reach a decision, or limited the rate of requests. Retry after the Retry-After delay. |
getVercelOidcToken() throws | Call it inside the request handler, not at module level. |
401 with expired or invalid access token from the MCP server | The server isn’t on the shared authorization server whose token endpoint the function used. Check its User session issuer under Settings > Authentication. |
403 from the MCP server | The agent may not connect to that server. Select Edit on the agent and add the server under MCP Selection. |
Adapt it to another platform
Section titled “Adapt it to another platform”Speakeasy accepts a token from any platform that meets these conditions:
- The token is a signed JWT. Its
issis an HTTPS URL, and the platform publishes its signing keys at an HTTPS JWKS URL. Both are usually in the platform’s discovery document, atISSUER/.well-known/openid-configuration. - The workload can request the token with exactly one audience: the Audience from Point it at Speakeasy. A token with any other audience, or with several, is refused.
- The token expires no more than an hour after the exchange.
- Its
subisn’t empty and isn’t the same as itsiss. - A token with a
jtiis accepted only once, so the workload needs a new token for each exchange.
Read a real token for the subject rather than guessing it. A subject that doesn’t match exactly admits nothing. Pick a subject the platform sets, one the workload can’t choose, because the access rule is the security boundary. Then enter the issuer URL, JWKS URL, and subject in the same steps, and post the token with the same request.
Speakeasy already knows the issuers of Claude Tag, GKE, and GitHub Actions. For those platforms, follow Set up Claude Tag with workload identity, Connect GKE workloads with workload identity, or Connect GitHub Actions jobs with workload identity instead.
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.
- To change the servers the function can reach, select Edit on the agent and save a new selection.
- The issuer and subject are fixed once saved. To trust a different issuer or admit a different subject, set it up again.
- To disconnect the platform, select Remove on its row, then remove the exchange from the function.
Removing access stops new exchanges but doesn’t end sessions already issued, which last up to 15 minutes. To cut off the function immediately, also revoke its sessions on MCP Sessions, or suspend 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 function acts as.
- Read Connect GitHub Actions jobs with workload identity for a platform Speakeasy already verified, where the device agent sets up Claude Code or Codex in the job.