Configure per-caller Credential Delegation

Add a Credential Delegation element to a surface so the gateway acquires and injects each caller’s OAuth token after a one-time consent step. Works on any surface type with any OAuth 2.0 Authorization Code API. Uses GitHub Copilot MCP as the sample target.

By the end of this guide, your surface injects each caller’s OAuth token on every forwarded request. Callers complete a one-time OAuth consent step to authorise access. The gateway stores their token and injects it automatically on subsequent calls.

Credential Delegation stores per-caller OAuth tokens in the gateway and injects them transparently, without exposing the OAuth App credentials to callers. Without it, calls run under a shared token, masking per-caller identity in the upstream API’s audit log. See Credentials and Caller context binding.

Use this guide when callers need to access a third-party API under their own account. This pattern works on any surface type (A2A or MCP) and with any OAuth 2.0 Authorization Code API.

MCP surface with caller context binding

Prerequisites

Do these steps before opening the gateway dashboard:

  1. Create a GitHub OAuth App at github.com/settings/developers. Enter this callback URL exactly during creation:

        https://GATEWAY_HOST/v1/identity/oauth/callback/github

    Note the Client ID and generate a Client Secret.

  2. Store the credentials as gateway secrets. Follow Manage secrets to create:

    • Secret ID GITHUB_CLIENT_ID, value: your App’s Client ID.
    • Secret ID GITHUB_CLIENT_SECRET, value: your App’s Client Secret.

Have these ready in the gateway:

  • A running gateway instance with Power User or Administrator role, and your public hostname (GATEWAY_HOST).
  • An existing MCP surface open in the canvas. See MCP surface starter if you need one.
  • A JWT issued by a trusted identity provider for each caller. The token must carry a unique per-user claim (sub or oid). If you use API key auth instead, issue one key per caller under API Keys in the sidebar.

Steps

Create a credential provider for GitHub

  1. In the sidebar, select Credentials, then select the Credential Providers tab.
  2. Select Add Credential Provider.
  3. Fill in the fields:
Credential Provider creation for Caller Context
FieldValue
NameGitHub
Provider IDgithub
TypeOAuth 2.0 Authorization Code
Authorization endpointhttps://github.com/login/oauth/authorize
Token endpointhttps://github.com/login/oauth/access_token
Client ID secretGITHUB_CLIENT_ID
Client secretGITHUB_CLIENT_SECRET
Default scopesrepo, read:user
Callback hosthttps://GATEWAY_HOST
Token refreshEnabled
  1. Confirm the Callback URL shown on the form matches what you entered in your GitHub App: https://GATEWAY_HOST/v1/identity/oauth/callback/github.
  2. Select Save.

Set the Managed Agent target

Surface target caller context MCP
  1. Open your MCP surface in the canvas.
  2. Select the Managed Agent node.
  3. Set the Target Endpoint to https://api.githubcopilot.com/mcp/.
  4. Close the editor.

Add Caller Context

  1. Drag a Caller Context element from the palette onto the canvas.
  2. Select the Caller Context node.
  3. Set Authentication Method to JWT Bearer.
  4. Accept the default Header (Authorization) and Scheme (Bearer).
  5. Close the editor.

Add the Identity element

The Identity element derives a stable per-caller DID from the validated credential. This DID appears as the workload identity in audit records and workload binding attestations.

  1. Drag an Identity element from the palette onto the canvas.
  2. Select the Identity node.
  3. Set Type to From JWT claim.
  4. Set Claim to sub. Use oid if your identity provider is Microsoft Entra.
  5. Close the editor.

Enable Credential Delegation

Surface target caller context MCP
  1. Drag a Credential Delegation element from the palette and connect it to the Managed Agent node.

  2. Configure the credential binding:

    FieldValue
    Credential ProviderGitHub
    Scopesrepo read:user
    Consent ModeOn demand
    Required ForAll outbound requests
    Inject AsBearer header

    Consent Mode controls what the agent receives on the first call when no token is stored yet.

    ModeWhat the agent receivesWhen to use
    On demand (default)A consent_required response with the authorisation URL. The agent or client retries after the user completes OAuth.Most clients. No MCP-specific support required.
    Pre-authorizeThe session is blocked at initialisation. OAuth must complete before any tool call proceeds.Clients that can display an auth prompt before the session starts.
    ElicitAn inline MCP elicitation prompt. The gateway holds the tool call open while OAuth completes, then resumes automatically.Clients that support MCP elicitation. No out-of-band retry required.

    Required For — All outbound requests means the gateway checks for a stored GitHub token before every forwarded call, including tools/list. If no token is stored for this caller, the gateway returns a consent_required response immediately without forwarding the request.

  3. Close the editor.

Save the surface

Select the green checkmark (Create surface) or Save changes. Changes apply immediately through hot reload.

On every subsequent request the gateway:

  1. Authenticates the caller via Authorization: Bearer (or X-API-Key header for API key auth).
  2. Derives the caller’s unique identity from the JWT claim (or from the API key if using API key auth).
  3. Checks for a stored OAuth token for that identity. If none is stored, returns consent_required.
  4. Injects the token as Authorization: Bearer <oauth-token> on the forwarded request to api.githubcopilot.com/mcp/.

Restrict access by Entra group membership (optional)

If callers authenticate with Microsoft Entra, add a surface-scope OPA policy to restrict access to specific security groups.

From the surface palette, drag a Policy element onto the canvas and open the policy editor. Enter:

package surface.policy

default allow := false

allow if {
    input.caller.jwt_bearer != null
    input.caller.jwt_bearer.iss == "https://login.microsoftonline.com/TENANT_ID/v2.0"
    "ALLOWED_GROUP_ID" in input.caller.jwt_bearer.groups
    not "RESTRICTED_GROUP_ID" in input.caller.jwt_bearer.groups
}

Replace TENANT_ID, ALLOWED_GROUP_ID, and RESTRICTED_GROUP_ID with values from your Entra configuration. Save and publish the policy.

Confirm

Replace GATEWAY_HOST and JWT_TOKEN with your actual values. JWT_TOKEN is a valid JWT issued by the identity provider you have configured as the gateway’s trusted issuer.

curl -X POST "https://GATEWAY_HOST/agents/call/github" \
  -H "Authorization: Bearer JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":"1","method":"tools/list","params":{}}'

Because Required For is set to All outbound requests, the gateway checks for a stored OAuth token before forwarding. When no token is stored for this caller:

{
  "type": "https://affinidi.com/atg/errors/consent-required",
  "title": "Credential Delegation Consent Required",
  "status": 401,
  "detail": "This channel requires delegated credentials. The user must authorize access via the provided URLs.",
  "consent_required": [
    {
      "provider_name": "GitHub",
      "authorization_url": "https://github.com/login/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&scope=repo+read%3Auser&state=...",
      "scopes": ["repo", "read:user"]
    }
  ]
}

Open the authorization_url in a browser, sign in to GitHub, and grant access. The gateway stores the token, scoped to this caller’s identity. Run the same command again. Expected: a JSON-RPC result listing the GitHub Copilot tools (get_me, list_repositories, create_issue, and others).

Test 2: tools/call should return data from the caller’s GitHub account

curl -X POST "https://GATEWAY_HOST/agents/call/github" \
  -H "Authorization: Bearer JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":"2","method":"tools/call","params":{"name":"get_me","arguments":{}}}'

Expected: the gateway injects Authorization: Bearer <oauth-token> and the tool returns the caller’s GitHub profile. Each caller sees only their own GitHub data.

Test 3: audit log should show delegation events for this caller

  1. In the dashboard, navigate to Credentials and select the Audit Log tab.
  2. Use the search field to filter entries by github.

Look for these event types produced during your test:

Event badgeWhen it appears
Consent RequiredThe gateway returned consent_required on the first call; no token was stored yet.
Consent GrantedThe user completed the OAuth flow and the gateway stored the token.
InjectedThe stored token was injected into a forwarded request on a subsequent call.

Each entry shows the provider name and the caller’s identity hash. Confirm that the Injected entries show github as the provider, confirming the token is stored per caller and is not shared.

Troubleshooting

SymptomLikely causeFix
consent_required on every call, even after completing OAuthThe OAuth callback did not reach the gateway, or the token was not stored.Check Credentials → Audit Log for a TokenIssued event with provider_id = github. Confirm the callback URL is publicly reachable from GitHub’s servers.
GitHub returns redirect_uri_mismatchThe callback URL in the GitHub App does not match the gateway’s.Copy the full callback URL from the credential provider form and paste it into the GitHub App Authorization callback URL field. Check for trailing slash differences.
401 Unauthorized with no consent_required bodyThe caller’s JWT was rejected before credential delegation ran.Confirm the JWT is valid and has not expired. If using API key auth, confirm the key is active and the Caller Context reads from X-API-Key.
tools/list returns consent_requiredRequired For is set to All outbound requests. The gateway checks for a stored OAuth token before every forwarded call, not just tools/call.This is expected behaviour. Complete the OAuth flow first, then retry.
All callers trigger the same consent flow, or share each other’s tokensThe Identity element is missing or misconfigured.Confirm the Identity element is present and its Type matches the auth method: From JWT claim for JWT bearer, From API key for API key auth. Without the correct type, all callers share the same stored token.
Gateway returns 502 Bad GatewayThe gateway cannot reach api.githubcopilot.com/mcp/.Confirm the gateway has outbound HTTPS access and the Target Endpoint is exactly https://api.githubcopilot.com/mcp/.
Stored token expired and calls start failingGitHub OAuth Apps do not issue long-lived refresh tokens by default.The caller must re-authorise by visiting the authorization_url again. For persistent access, consider a GitHub App (which supports refresh tokens) instead.
OPA policy rejects all calls even when credentials appear correctIssuer claim mismatch, or groups claim absent from the JWT.Confirm the iss value in the Rego policy matches the token’s iss exactly, including any trailing slash. Verify that group claims are enabled in the IdP app registration’s token configuration.
Delegation audit log shows no entriesThe Identity element is not configured, or the caller’s identity cannot be derived.Confirm the Identity element is present and its Type matches the auth method. Without a valid per-caller identity, delegation events cannot be scoped and are not recorded.

Next steps

  • Credentials: conceptual model for credential providers, OAuth flows, consent modes, and per-user token isolation.
  • Secrets reference: how to store and reference secret material safely.