Skip to content
Status

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:

When the function needs to call an MCP server:

  • The function calls getVercelOidcToken() from @vercel/oidc with Speakeasy’s audience. Vercel exchanges the deployment’s OIDC token for one carrying that aud. Its subject has the form owner: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.

  • 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/oidc 3.7.0 or later, which added the audience and jti options.
  • 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.

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.

On Identity provider, fill in:

FieldValue for Vercel
NameWhat the platform is called in Speakeasy, such as Vercel. It isn’t used for matching.
Issuer URLhttps://oidc.vercel.com/TEAM_SLUG
JWKS URLhttps://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:

Terminal window
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.

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:

SubjectMatchAdmits
owner:TEAM_SLUG:project:PROJECT_NAME:environment:productionThis subject onlyThe project’s production deployments. Recommended.
owner:TEAM_SLUG:project:PROJECT_NAME:environment:Any subject starting with itEvery environment of the project: production, preview, development, and any Custom Environment.
owner:TEAM_SLUG:Any subject starting with itEvery 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 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 this aud, 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.

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:

  • audience makes Vercel issue the token for Speakeasy. A token without it carries https://vercel.com/TEAM_SLUG, and Speakeasy refuses it.
  • Speakeasy accepts a token with a jti only once, so pass a new jti on 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, add resource with the server’s URL, as in Exchange a platform token.
  • In a function that calls servers often, reuse the access token until expires_in runs 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 asks for one exchange from the platform. Deploy the function to production and request the route:

Terminal window
curl https://PRODUCTION_DOMAIN/api/speakeasy-tools

The 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.

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 seesWhat to check
invalid_grant from the token endpointaud is the Audience from Point it at Speakeasy, and the token carries no other audience.
invalid_grant from the token endpointiss 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 endpointsub 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 endpointexp is no more than an hour away. Speakeasy refuses a token that’s valid for longer.
invalid_grant from the token endpointThe token hasn’t been exchanged before. Pass a new jti on every call.
invalid_grant from the token endpointThe agent is active, not suspended or revoked, and its owner is still in the organization.
temporarily_unavailable from the token endpointSpeakeasy couldn’t reach a decision, or limited the rate of requests. Retry after the Retry-After delay.
getVercelOidcToken() throwsCall it inside the request handler, not at module level.
401 with expired or invalid access token from the MCP serverThe 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 serverThe agent may not connect to that server. Select Edit on the agent and add the server under MCP Selection.

Speakeasy accepts a token from any platform that meets these conditions:

  • The token is a signed JWT. Its iss is an HTTPS URL, and the platform publishes its signing keys at an HTTPS JWKS URL. Both are usually in the platform’s discovery document, at ISSUER/.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 sub isn’t empty and isn’t the same as its iss.
  • A token with a jti is 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.

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.