# Connect any OIDC platform with workload identity

Any platform that signs OIDC tokens for its workloads can sign in with [workload identity](/docs/ai-control-plane/identity/agents/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](https://vercel.com/docs/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](#adapt-it-to-another-platform) change.

This guide covers:

- [How the connection works](#how-the-connection-works)
- [Prerequisites](#prerequisites)
- [Start the setup](#start-the-setup)
- [Enter the identity provider](#enter-the-identity-provider)
- [Choose which workloads](#choose-which-workloads)
- [Point it at Speakeasy](#point-it-at-speakeasy)
- [Exchange the token and call a server](#exchange-the-token-and-call-a-server)
- [Check it worked](#check-it-worked)
- [Troubleshooting](#troubleshooting)
- [Adapt it to another platform](#adapt-it-to-another-platform)
- [Manage and revoke access](#manage-and-revoke-access)

## How the connection works

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](/docs/ai-control-plane/reference/device-agent) signs in as a workload only from GitHub Actions jobs, so it doesn't apply here either.

## 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](https://vercel.com/docs/oidc/api).
- `@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](#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](/docs/ai-control-plane/identity/agents/workload-identity#exchange-a-platform-token). If the setup reports that no token endpoint is available, contact Speakeasy to enable one.

## 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](/docs/ai-control-plane/identity/agents/authorizing-agents#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

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:

```bash
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

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

**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`.

## 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:

```ts filename="app/api/speakeasy-tools/route.ts"
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](/docs/ai-control-plane/identity/agents/workload-identity#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

**Check it worked** asks for one exchange from the platform. Deploy the function to production and request the route:

```bash
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](/docs/ai-control-plane/mcp-gateway/access/clients-and-sessions#organization-wide-view) 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

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:

```ts
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](/docs/ai-control-plane/org-admin/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

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](/docs/ai-control-plane/guides/claude-tag), [Connect GKE workloads with workload identity](/docs/ai-control-plane/guides/gke), or [Connect GitHub Actions jobs with workload identity](/docs/ai-control-plane/guides/github-actions) instead.

## 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](/docs/ai-control-plane/identity/agents#suspend-revoke-or-delete-an-agent). Every change is recorded in [Audit Logs](/docs/ai-control-plane/org-admin/audit-logs).

## Next steps

- Read [Workload identity](/docs/ai-control-plane/identity/agents/workload-identity) for how trusted platforms, access rules, and token exchange work.
- Read [Authorizing agents](/docs/ai-control-plane/identity/agents/authorizing-agents) to scope the agent the function acts as.
- Read [Connect GitHub Actions jobs with workload identity](/docs/ai-control-plane/guides/github-actions) for a platform Speakeasy already verified, where the device agent sets up Claude Code or Codex in the job.

---

Index of every Speakeasy page for agents: [llms.txt](https://www.speakeasy.com/llms.txt). AI control plane and MCP gateway content in one file: [llms-full.txt](https://www.speakeasy.com/llms-full.txt). Any page is available as markdown by appending `.md` to its URL or by requesting it with `Accept: text/markdown`.
