-
Notifications
You must be signed in to change notification settings - Fork 315
Description
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-- Theaudclaim 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.) │
└──────────────────────┘
- The workflow compiler detects
auth.type: github-oidcand ensuresid-token: writeis in the job permissions. - The compiler passes
ACTIONS_ID_TOKEN_REQUEST_URLandACTIONS_ID_TOKEN_REQUEST_TOKENto the gateway container via-eflags. - 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). - The gateway sets
Authorization: Bearer <jwt>on the proxied request. - 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 claimsOIDC 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.,
mainonly) - Restrict to specific workflow files
- Audit which actor and commit triggered each MCP tool call
Implementation Considerations
Compiler changes (minimal)
- Add
authfield to the MCP server schema (mcp_config_schema.json) withtypeandaudienceproperties. - When
auth.type: github-oidcis present, auto-addid-token: writeto job permissions (similar to howsafe-outputsauto-adds permissions insafe_outputs_permissions.go). - Pass
ACTIONS_ID_TOKEN_REQUEST_URLandACTIONS_ID_TOKEN_REQUEST_TOKENto the gateway container via-eflags.
Gateway changes
- Add token acquisition logic: call
ACTIONS_ID_TOKEN_REQUEST_URLwithACTIONS_ID_TOKEN_REQUEST_TOKENas bearer auth, specifying the configuredaudience. - 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 withauth.type: github-oidc. - Existing static
headers:behavior remains unchanged for servers withoutauth.
Gateway schema changes
- Add
authobject to per-server config inmcp-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
authcontinue to use staticheaders:as today. authandheaderscould coexist (OIDC setsAuthorization, 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
- actions/toolkit#2048 -- Request for OIDC tokens with >5 minute expiry (reinforces why gateway-level refresh is needed)
- GitHub Docs: About security hardening with OpenID Connect
- GitHub Blog: Actions OIDC tokens now support repository custom properties (March 2026) -- Custom properties in OIDC tokens enable richer ABAC policies