Skip to content
API Reference
Architecture

Authorization

Understanding RFC 8693 token exchange for delegated access

Keycard implements RFC 8693 OAuth 2.0 Token Exchange to enable secure, delegated access to protected resources. This allows applications to exchange one type of token for another, enabling AI agents and MCP servers to access resources on behalf of authenticated users.

Token exchange is the process of converting one token into another to access different resources while maintaining the user’s identity and authorization context.

Consider an AI agent that needs to:

  1. Authenticate the user
  2. Access the user’s GitHub repositories
  3. Access the user’s Google Calendar
  4. Access the user’s Slack workspace

Without token exchange, you would need to:

  • Build custom OAuth flows for each service
  • Manage multiple token lifecycles
  • Handle refresh logic for each provider
  • Store credentials securely for each integration

With Keycard’s token exchange:

User Token (Identity) → Exchange → GitHub Token
→ Exchange → Google Token
→ Exchange → Slack Token

One authentication flow, automatic token exchange for all resources.


Keycard implements RFC 8693: OAuth 2.0 Token Exchange, an IETF standard for exchanging OAuth tokens.

Subject TokenThe input token (user’s identity token from Keycard)
Requested TokenThe output token (resource-specific access token)
ResourceThe target API or service requiring access
ScopeThe permissions requested for the resource
Actor Token (optional)Token representing a service acting on behalf of the user
urn:ietf:params:oauth:grant-type:token-exchange

This special grant type tells the authorization server to perform token exchange rather than standard OAuth flows.


sequenceDiagram
    participant User
    participant Agent as AI Agent/MCP Server
    participant KC as Keycard
    participant Resource as External Resource

    User->>Agent: Make request
    Agent->>KC: Exchange token for GitHub access
    KC->>Resource: Request user's GitHub token
    Resource->>User: Authorize access
    User->>Resource: Grant permission
    Resource->>KC: GitHub access token
    KC->>Agent: Exchanged token
    Agent->>Resource: Access GitHub API
    Resource->>Agent: API response
    Agent->>User: Result
  1. User Authenticates

    User authenticates to your application via Keycard:

    // User logs in, application receives Keycard JWT
    const userToken = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...";

    This token represents the user’s identity and their authorization for your application.

  2. Application Requests Resource Access

    Your application needs to access a protected resource (e.g., GitHub API):

    // Application declares dependency on GitHub
    const dependencies = {
    "https://api.github.com": ["repo", "user:email"],
    };
  3. Token Exchange Request

    Application calls Keycard’s token exchange endpoint:

    POST https://z-abc123.keycard.cloud/oauth/token
    Content-Type: application/x-www-form-urlencoded
    grant_type=urn:ietf:params:oauth:grant-type:token-exchange&
    subject_token=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...&
    subject_token_type=urn:ietf:params:oauth:token-type:access_token&
    resource=https://api.github.com&
    scope=repo user:email&
    client_id=<your-application-client-id>&
    client_secret=<your-application-client-secret>

    Parameters:

    grant_typeToken exchange grant type (RFC 8693)
    subject_tokenUser’s Keycard JWT (input token)
    subject_token_typeType of input token (access_token)
    resourceTarget resource identifier (GitHub API)
    scopeRequested permissions
    client_id / client_secretApplication credentials
  4. Keycard Processes Exchange

    Keycard validates and processes the request:

    1. Validate subject token: Verify JWT signature, expiration, audience
    2. Check authorization: Ensure user authorized app to access GitHub
    3. Check dependencies: Verify app declared GitHub as a dependency
    4. Retrieve or fetch token: Get cached GitHub token or initiate OAuth flow
    5. Issue exchanged token: Return resource-specific access token
  5. Token Exchange Response

    Keycard returns the exchanged token:

    {
    "access_token": "gho_abc123def456...",
    "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
    "token_type": "Bearer",
    "expires_in": 3600,
    "scope": "repo user:email"
    }
  6. Access Protected Resource

    Application uses the exchanged token:

    const response = await fetch("https://api.github.com/user/repos", {
    headers: {
    Authorization: `Bearer ${exchangedToken.access_token}`,
    Accept: "application/vnd.github.v3+json",
    },
    });

import { STSClient } from "./services/sts-client";
import { requireBearerAuth } from "@keycardlabs/mcp-oauth/server/auth/middleware/bearerAuth";
// Protect your endpoint
app.post(
"/mcp",
requireBearerAuth({ requiredScopes: [] }),
async (req, res) => {
// User's Keycard JWT available via middleware
const { authInfo } = req;
// Create STS client
const stsClient = new STSClient(process.env.KEYCARD_STS_ISSUER_URL);
// Exchange token for GitHub access
const githubToken = await stsClient.exchange(
authInfo,
"https://api.github.com",
["repo", "user:email"],
);
// Use exchanged token
const repos = await fetch("https://api.github.com/user/repos", {
headers: { Authorization: `Bearer ${githubToken.access_token}` },
});
res.json(await repos.json());
},
);
from keycardai.mcp.server.auth import AuthProvider, AccessContext
auth = AuthProvider(
mcp_server_name="GitHub MCP Server",
base_url="https://z-abc123.keycard.cloud"
)
@mcp.tool(name="list_repos")
@auth.grant("https://api.github.com")
async def list_repos(ctx, access_context: AccessContext):
# Check for errors
if access_context.has_errors():
return {"error": "GitHub access not authorized"}
# Token exchange handled automatically by decorator
github_token = access_context.access_token
# Use exchanged token
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.github.com/user/repos",
headers={"Authorization": f"Bearer {github_token}"}
)
return response.json()

For frameworks without native Keycard SDK support:

src/services/sts-client.ts
export class STSClient {
constructor(private stsBaseUrl: string) {}
async exchange(
authInfo: AuthInfo,
resource: string,
scopes?: string[],
): Promise<{ access_token: string }> {
const params = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: authInfo.token,
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
resource: resource,
});
if (scopes?.length) {
params.set("scope", scopes.join(" "));
}
const response = await fetch(`${this.stsBaseUrl}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`);
}
return await response.json();
}
}

The token being exchanged - typically a Keycard JWT representing the user:

{
"iss": "https://z-abc123.keycard.cloud",
"sub": "user-123",
"aud": "https://mcp.example.com",
"scope": "mcp:tools",
"exp": 1234567890,
"iat": 1234564290
}

Token Type: urn:ietf:params:oauth:token-type:access_token

The exchanged token for accessing the specific resource:

{
"access_token": "gho_abc123def456...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "repo user:email",
"refresh_token": "ghr_xyz789..."
}

Token Type: urn:ietf:params:oauth:token-type:access_token or urn:ietf:params:oauth:token-type:refresh_token


Keycard supports transitive token exchange - applications can exchange tokens on behalf of other applications.

Example: AI Agent → MCP Server → External API

Section titled “Example: AI Agent → MCP Server → External API”
User
└─> Authenticates to AI Agent
└─> AI Agent calls MCP Server (with user token)
└─> MCP Server exchanges for GitHub token
└─> MCP Server accesses GitHub API
sequenceDiagram
    participant User
    participant Agent as AI Agent
    participant MCP as MCP Server
    participant KC as Keycard
    participant GitHub

    User->>Agent: Request with auth
    Agent->>KC: Exchange user token for MCP token
    KC->>Agent: MCP access token
    Agent->>MCP: Call tool with MCP token
    MCP->>KC: Exchange MCP token for GitHub token
    KC->>GitHub: Request user's GitHub access
    GitHub->>KC: GitHub token
    KC->>MCP: GitHub access token
    MCP->>GitHub: Access user's repos
    GitHub->>MCP: Repo data
    MCP->>Agent: Tool result
    Agent->>User: Response

Keycard SDKs handle transitive exchanges automatically:

# AI Agent code
@agent.tool(name="get_github_repos")
@auth.grant("https://mcp.example.com") # MCP server
async def get_repos(ctx, access_context):
# Token exchange to MCP server handled automatically
mcp_token = access_context.access_token
# Call MCP server
result = await mcp_client.call_tool("list_repos", token=mcp_token)
return result
# MCP Server code (separate service)
@mcp.tool(name="list_repos")
@auth.grant("https://api.github.com") # GitHub API
async def list_repos(ctx, access_context):
# Token exchange to GitHub handled automatically
github_token = access_context.access_token
# Access GitHub
repos = await github_client.get_repos(token=github_token)
return repos

User authenticates once, two automatic token exchanges occur.


Keycard caches exchanged tokens to improve performance:

  • Duration: Based on token expiration (typically 1 hour)
  • Scope: Per user + resource + scope combination
  • Invalidation: Automatic on token expiration or user revocation
Without CachingWith Caching
OAuth flow on every requestOAuth flow only on cache miss
High latency (redirects + API calls)Low latency (cache lookup)
Rate limit pressure on providersReduced API calls
Poor user experienceSeamless experience
  • Token expires (1 hour default)
  • User revokes access
  • Application scopes change
  • Resource provider rotates keys

Keycard SDKs implement intelligent caching:

// First call: Full OAuth flow + token exchange
const token1 = await exchangeToken(userToken, "github", ["repo"]);
// Subsequent calls within 1 hour: Cached
const token2 = await exchangeToken(userToken, "github", ["repo"]); // Fast!

  • Token Validation
    Always validate exchanged tokens before use:

  • Verify token signature
    Use the resource provider’s JWKS endpoint to validate JWT signatures.

  • Check expiration
    Validate exp claim before using the token. Refresh if expired.

  • Validate audience
    Ensure the aud claim matches your application or the intended resource.

  • Check scope
    Verify the token has the required scopes for the operation.

  • Handle errors gracefully
    Token exchange can fail (revoked access, expired tokens, network issues). Implement retries and error handling.

PracticeWhyHow
Least PrivilegeMinimize attack surfaceRequest only required scopes
Short LifetimesLimit token exposure windowUse 1-hour access tokens
Secure StoragePrevent token theftNever log or persist exchanged tokens
Scope ValidationEnforce authorizationCheck token scopes before API calls
Audit LoggingDetect suspicious activityLog all token exchange events

Exchanged tokens are bound to:

  • User: Can only be used for the original user’s resources
  • Application: Can only be used by the requesting application
  • Resource: Can only access the specified resource

This prevents token reuse attacks and unauthorized access.


Cause: Subject token is invalid, expired, or revoked

Solution:

  • Refresh the subject token
  • Re-authenticate the user
  • Check token expiration
try {
const token = await exchangeToken(userToken, resource, scopes);
} catch (error) {
if (error.error === "invalid_grant") {
// Re-authenticate user
redirectToLogin();
}
}

Cause: Application not authorized to access resource

Solution:

  • Verify application has declared dependency on resource
  • Check user has authorized application for resource
  • Verify application credentials are correct

Cause: Requested scopes not available or not authorized

Solution:

  • Request only available scopes
  • Check resource supports requested scopes
  • User may need to re-authorize with additional scopes

Cause: Temporary issue with Keycard or resource provider

Solution:

  • Implement exponential backoff
  • Retry the request
  • Fall back to cached tokens if available

AspectDirect OAuthToken Exchange
SetupPer-resource implementationSingle Keycard integration
Token ManagementManual refresh per providerAutomatic via Keycard
User ExperienceMultiple authorization promptsSingle authorization
SecurityManage secrets per providerCentralized secret management
AuditSeparate logs per providerUnified audit trail
AspectAPI KeysToken Exchange
User ContextNo user identityMaintains user identity
RevocationManual rotationInstant revocation
ExpirationOften permanentShort-lived (1 hour)
Scope ControlAll-or-nothingFine-grained scopes
DelegationNot possibleNative support