Skip to content

Feature Request: GitHub OIDC token support for custom HTTP MCP server authentication #23566

@bbonafed

Description

@bbonafed

Problem

Custom HTTP MCP servers that require authentication currently rely on static, long-lived API keys or tokens stored in GitHub Secrets and injected via headers: in workflow frontmatter:

mcp-servers:
  my-server:
    url: "https://my-server.example.com/mcp"
    headers:
      Authorization: "Bearer ${{ secrets.MY_MCP_TOKEN }}"
    allowed: ["*"]

This works, but has significant security and operational drawbacks:

  • Long-lived secrets -- The API key/token must be stored indefinitely in GitHub Secrets and on the MCP server. If either side is compromised, the key is usable until manually rotated.
  • No identity context -- The MCP server receives an opaque token with no information about which repository, workflow, actor, or commit initiated the request. Access control is all-or-nothing.
  • Manual rotation burden -- Key rotation requires coordinated updates to both the GitHub Secret and the MCP server configuration, with risk of downtime if not done carefully.
  • Secret sprawl -- Each custom MCP server requires its own stored secret, multiplied across organizations and repositories.

GitHub Actions already solves this problem for cloud providers (AWS, Azure, GCP) via OIDC Workload Identity Federation. The same pattern should be available for custom HTTP MCP servers.

Proposed Solution

Add a github-oidc authentication type for custom HTTP MCP servers. The MCP gateway would handle OIDC token acquisition and refresh transparently, eliminating stored secrets entirely.

Proposed UX

permissions:
  id-token: write

mcp-servers:
  my-server:
    url: "https://my-server.example.com/mcp"
    auth:
      type: github-oidc
      audience: "https://my-server.example.com"
    allowed: ["*"]
  • auth.type: github-oidc -- Tells the gateway to authenticate using GitHub Actions OIDC tokens.
  • auth.audience -- The aud claim value for the OIDC token (typically the MCP server's URL). Allows the server to reject tokens not intended for it.

How It Would Work

┌──────────────────────┐
│   Agent (Copilot/     │
│   Claude/Codex)       │
└──────────┬───────────┘
           │ tool call
           ▼
┌──────────────────────┐     ┌──────────────────────────┐
│     MCP Gateway      │────>│ GitHub OIDC Token        │
│                      │<────│ Endpoint                 │
│  1. Receive tool call│     │ (ACTIONS_ID_TOKEN_       │
│  2. Request fresh JWT│     │  REQUEST_URL)            │
│  3. Forward with     │     └──────────────────────────┘
│     Authorization:   │
│     Bearer <jwt>     │
└──────────┬───────────┘
           │ proxied request + fresh JWT
           ▼
┌──────────────────────┐     ┌──────────────────────────┐
│  Custom HTTP MCP     │────>│ GitHub JWKS              │
│  Server              │     │ (token.actions.          │
│                      │     │  githubusercontent.com/   │
│  Validates JWT:      │     │  .well-known/jwks)       │
│  - Signature (JWKS)  │     └──────────────────────────┘
│  - Issuer            │
│  - Audience          │
│  - Claims (repo, org,│
│    workflow, etc.)    │
└──────────────────────┘
  1. The workflow compiler detects auth.type: github-oidc and ensures id-token: write is in the job permissions.
  2. The compiler passes ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN to the gateway container via -e flags.
  3. When the gateway proxies a request to the upstream MCP server, it requests a fresh OIDC JWT from the GitHub token endpoint (with the configured audience).
  4. The gateway sets Authorization: Bearer <jwt> on the proxied request.
  5. The upstream MCP server validates the JWT signature against GitHub's public JWKS, checks the issuer, audience, and any claims it cares about (e.g., repository_owner, repository, workflow_ref).

Why the gateway is the right place

The 5-minute OIDC token lifetime is a known constraint (actions/toolkit#2048). Static headers: resolved at compile time cannot refresh tokens. But the gateway is a long-running process within the job that already proxies all MCP traffic -- it can request fresh tokens per-call or cache them with time-based refresh. The short token lifetime becomes an advantage (minimal blast radius) rather than a limitation.

Benefits

Static API Key (today) Gateway OIDC (proposed)
Stored secrets Required on both sides None
Token lifetime Indefinite ~5 minutes (auto-refreshed)
Identity context None (opaque key) Repo, org, actor, workflow, commit SHA
Rotation Manual, coordinated Automatic (keys rotate with GitHub's JWKS)
Access control All-or-nothing Claim-based (per-repo, per-workflow, per-branch)
Blast radius on compromise Full access until rotated 5-minute window, single request

Server-Side Verification Example

MCP server operators would verify the OIDC JWT using standard libraries. Example (Python):

import httpx
from jose import jwt, jwk

GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"
GITHUB_JWKS_URL = f"{GITHUB_OIDC_ISSUER}/.well-known/jwks"
EXPECTED_AUDIENCE = "https://my-server.example.com"
ALLOWED_REPOS = ["my-org/my-repo", "my-org/other-repo"]

async def verify_github_oidc_token(token: str) -> dict:
    """Verify a GitHub Actions OIDC JWT and return its claims."""
    # Fetch GitHub's public keys
    async with httpx.AsyncClient() as client:
        jwks = (await client.get(GITHUB_JWKS_URL)).json()

    # Verify signature, issuer, audience, and expiry
    claims = jwt.decode(
        token,
        jwks,
        algorithms=["RS256"],
        issuer=GITHUB_OIDC_ISSUER,
        audience=EXPECTED_AUDIENCE,
    )

    # Enforce repository-level access control
    if claims.get("repository") not in ALLOWED_REPOS:
        raise PermissionError(f"Repository {claims['repository']} is not authorized")

    return claims

OIDC Token Claims Available for Access Control

The GitHub Actions OIDC token includes rich context that MCP servers can use for fine-grained authorization:

{
  "iss": "https://token.actions.githubusercontent.com",
  "aud": "https://my-server.example.com",
  "sub": "repo:my-org/my-repo:ref:refs/heads/main",
  "repository": "my-org/my-repo",
  "repository_owner": "my-org",
  "repository_owner_id": "12345",
  "actor": "username",
  "workflow_ref": "my-org/my-repo/.github/workflows/agent.yaml@refs/heads/main",
  "event_name": "issues",
  "ref": "refs/heads/main",
  "sha": "abc123...",
  "runner_environment": "github-hosted",
  "exp": 1772542150,
  "iat": 1772541850
}

This enables policies like:

  • Only allow requests from specific repositories or organizations
  • Restrict to specific branches (e.g., main only)
  • Restrict to specific workflow files
  • Audit which actor and commit triggered each MCP tool call

Implementation Considerations

Compiler changes (minimal)

  • Add auth field to the MCP server schema (mcp_config_schema.json) with type and audience properties.
  • When auth.type: github-oidc is present, auto-add id-token: write to job permissions (similar to how safe-outputs auto-adds permissions in safe_outputs_permissions.go).
  • Pass ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN to the gateway container via -e flags.

Gateway changes

  • Add token acquisition logic: call ACTIONS_ID_TOKEN_REQUEST_URL with ACTIONS_ID_TOKEN_REQUEST_TOKEN as bearer auth, specifying the configured audience.
  • Cache tokens with TTL-based refresh (e.g., refresh when <60 seconds remain on the 5-minute lifetime).
  • Set Authorization: Bearer <jwt> on proxied requests to servers configured with auth.type: github-oidc.
  • Existing static headers: behavior remains unchanged for servers without auth.

Gateway schema changes

  • Add auth object to per-server config in mcp-gateway-config.schema.json:
"auth": {
  "type": "object",
  "properties": {
    "type": {
      "type": "string",
      "enum": ["github-oidc"],
      "description": "Authentication method for this upstream server"
    },
    "audience": {
      "type": "string",
      "description": "The audience claim for the OIDC token"
    }
  },
  "required": ["type"]
}

Backward compatibility

  • Fully backward compatible. Servers without auth continue to use static headers: as today.
  • auth and headers could coexist (OIDC sets Authorization, static headers provide additional custom headers).

Alternatives Considered

Alternative Why it doesn't work
Static OIDC token in headers: Tokens expire in 5 minutes; headers: are resolved once at compile time and cannot refresh.
Token exchange endpoint (OIDC -> session token) headers: only support ${{ secrets.* }}; step outputs (${{ steps.*.outputs.* }}) cannot be injected into MCP headers.
HMAC request signing Requires per-request signature computation; the gateway sends static headers, not computed signatures.
mTLS (client certificates) No client certificate configuration exposed for custom HTTP MCP servers.
MCP Script proxy (per-call OIDC) Requires duplicating every tool as a local MCP script, loses dynamic tool discovery, adds latency, defeats MCP's purpose.

Related

Metadata

Metadata

Assignees

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions