You can ship a beautiful web app and still watch your first remote MCP integration fall over for reasons that have nothing to do with your Lambda code. One connector completes OAuth and lists tools. Another finishes the redirect and shows disconnected anyway. A third negotiates metadata like a textbook case while your logs say nothing useful. That's the normal mess of putting Model Context Protocol in front of real users. One end-to-end story beats a pile of partial tutorials.

This post is that story. You'll wire OAuth 2.1-style flows with Amazon Cognito as the token issuer, DynamoDB for short-lived authorization state, a dedicated Cognito app client so MCP tokens never double as your web session, and the official MCP TypeScript SDK for the tools your product actually sells. Expect endpoint-level detail: validation order, consent UX, how to keep web and MCP tokens apart, and what belongs in your tool layer once the bearer token is real. The outcome is an integration you can stand behind in architecture review: explicit token boundaries, observable failure modes, and behavior aligned with how major MCP hosts actually connect.

If MCP or OAuth vocabulary is unfamiliar, skim Quick terminology next. If you're ready to build, jump to What you build and follow the steps in order.

Quick terminology

Before you start building, here's a plain-language map of the terms you'll see:

  • MCP client. The app connecting to your server, for example Claude, Cursor, ChatGPT, or Inspector.

  • MCP server. Your API that exposes tools and requires authorization.

  • Authorization server. The service that handles sign-in and token issuance. In this guide, that is Cognito plus your auth routes.

  • DCR. Dynamic Client Registration. The client asks your server for a client_id at connection time.

  • CIMD. Client ID Metadata Documents. A newer pattern where the client uses a URL as identity.

  • PKCE. A proof step that protects authorization code exchange from interception attacks.

What you build

You build two discovery endpoints, four OAuth routes, and one MCP route:

  1. GET /.well-known/oauth-protected-resource. Clients read this first after a 401 from MCP. It points them at your authorization-server metadata and defines the protected resource (RFC 9728).

  2. GET /.well-known/oauth-authorization-server. It's your public OAuth menu merged with Cognito’s issuer and JWKS fields. Clients learn your authorize URL, token URL, PKCE support, and registration URL (RFC 8414).

  3. POST /oauth/register. It lets each connector introduce itself without pre-sharing secrets. You return a client_id and optionally store client_name by redirect URI for later UI.

  4. GET /oauth/client-info. It powers your consent screen. You look up stored registration data for a redirect URI so the user sees a human-readable app label.

  5. POST /oauth/authorize/{resourceContext}. The {resourceContext} path segment is optional. You can use a fixed path such as /oauth/authorize and carry workspace, tenant, or other ids only in scope or query parameters if you prefer. It runs after the user is signed in. You validate PKCE, access to whatever resource you are granting, and redirect URI, then issue a short-lived authorization code.

  6. POST /oauth/token. It exchanges that code for tokens. You verify PKCE and redirect matching, then hand back access and refresh tokens from Cognito.

  7. POST / for MCP JSON-RPC. It's the actual MCP surface. Unauthenticated calls get a 401 so clients begin OAuth. After auth, the same route serves tools and RPC methods.

You back this with Cognito for tokens, DynamoDB for authorization codes and registration rows, and Lambda (or similar) for each route. Environment variables you wire in practice include the user pool ID, Cognito OAuth app client ID, public app host for authorize links, and base URL for your auth API.

Request flow

The diagram below is the spine of the whole integration: MCP entry, auth API, Cognito, and where you store short-lived state. The MCP connector (the host app, for example Claude or Cursor) drives discovery, registration, token exchange, and MCP calls. Your web app is where the user signs in and approves access so your auth API can mint the authorization code.

Sequence diagram showing the OAuth 2.0 PKCE authorization flow between McpConnector, WebApp, McpServer, AuthApi, Cognito, and DynamoDb.

Step one: publish auth metadata

From the connector side, this is a two-hop discovery flow. After the user clicks "Connect" or "Authenticate," the connector calls your MCP JSON-RPC endpoint without a bearer token. Your MCP handler should answer with 401 Unauthorized and a WWW-Authenticate header so the client knows where discovery starts.

WWW-Authenticate: Bearer resource_metadata="https://<mcpHost>/.well-known/oauth-protected-resource"

The JSON body can still be a JSON-RPC error (for example Unauthorized) so humans and logs see a clear failure.

1) Protected resource metadata

Implement GET /.well-known/oauth-protected-resource (RFC 9728) with at least:

  • resource: your MCP base URL.

  • authorization_servers: where clients should fetch authorization-server metadata.

  • bearer_methods_supported: usually ["header"].

If authorization_servers points to the wrong host, clients follow the wrong chain and auth fails even when your token endpoint is fine.

Sample /.well-known/oauth-protected-resource response:

{
  "resource": "https://mcp.example.com",
  "authorization_servers": ["https://mcp.example.com"],
  "bearer_methods_supported": ["header"]
}
{
  "resource": "https://mcp.example.com",
  "authorization_servers": ["https://mcp.example.com"],
  "bearer_methods_supported": ["header"]
}
{
  "resource": "https://mcp.example.com",
  "authorization_servers": ["https://mcp.example.com"],
  "bearer_methods_supported": ["header"]
}
{
  "resource": "https://mcp.example.com",
  "authorization_servers": ["https://mcp.example.com"],
  "bearer_methods_supported": ["header"]
}
2) Authorization server metadata

Once the connector reads authorization_servers, it calls GET /.well-known/oauth-authorization-server (RFC 8414) on that host.

Implement this endpoint by loading Cognito’s own OpenID document from https://cognito-idp.<region>.amazonaws.com/<userPoolId>/.well-known/openid-configuration, then merging your overrides on top. This keeps Cognito issuer and JWKS fields aligned with minted tokens, while you control authorize, register, and token routes.

Set at least these fields from your deployment:

  • authorization_endpoint to your product host, for example https://<appHost>/oauth/authorize (query parameters for the authorize request typically include client_id, redirect_uri, response_type=code, PKCE fields, and state).

  • token_endpoint and registration_endpoint to your auth API base URL, for example https://<authApi>/oauth/token and https://<authApi>/oauth/register.

  • token_endpoint_auth_methods_supported to ["none"] because the connector is a public client without a static secret at your token endpoint.

  • code_challenge_methods_supported to ["S256"] for PKCE.

  • resource_parameter_supported to true if you follow MCP authorization and pass the resource indicator.

If the fetch to Cognito fails, return 500 so clients don't cache a half-built document.

Sample /.well-known/oauth-authorization-server response:

{
  "issuer": "https://cognito-idp.us-east-1.amazonaws.com/your_pool",
  "authorization_endpoint": "https://your-app.com/oauth/authorize",
  "token_endpoint": "https://your-api.com/oauth/token",
  "registration_endpoint": "https://your-api.com/oauth/register",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "response_types_supported": ["code"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"]
}
{
  "issuer": "https://cognito-idp.us-east-1.amazonaws.com/your_pool",
  "authorization_endpoint": "https://your-app.com/oauth/authorize",
  "token_endpoint": "https://your-api.com/oauth/token",
  "registration_endpoint": "https://your-api.com/oauth/register",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "response_types_supported": ["code"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"]
}
{
  "issuer": "https://cognito-idp.us-east-1.amazonaws.com/your_pool",
  "authorization_endpoint": "https://your-app.com/oauth/authorize",
  "token_endpoint": "https://your-api.com/oauth/token",
  "registration_endpoint": "https://your-api.com/oauth/register",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "response_types_supported": ["code"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"]
}
{
  "issuer": "https://cognito-idp.us-east-1.amazonaws.com/your_pool",
  "authorization_endpoint": "https://your-app.com/oauth/authorize",
  "token_endpoint": "https://your-api.com/oauth/token",
  "registration_endpoint": "https://your-api.com/oauth/register",
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "response_types_supported": ["code"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"]
}

Before you test any connector, curl this URL from the same network your users use and check that every hostname resolves to the API you think it does (see Useful notes if something works in one client but not another).

Step two: implement registration

From the connector side, this is still pre-user-consent. The connector sends your /oauth/register payload in the background and stores the returned client_id for the next authorize and token calls.

Implement POST /oauth/register (RFC 7591). The body is JSON. Connectors send a mix of required and optional fields. You validate a small required set, cap size, and list optional RFC fields explicitly in your schema so validation stays predictable while clients can still send token_endpoint_auth_method, scope, and similar keys.

Concrete validation that works in production:

  • Require redirect_uris: non-empty array, cap list length (for example at most five URIs) and cap each string length (for example 2048 characters) so storage cannot be flooded.

  • Accept optional client_name with a max length (for example 100 characters).

  • Accept optional RFC 7591 fields such as grant_types, response_types, scope, token_endpoint_auth_method, and marketing URIs even if you do not persist them. Clients sometimes send token_endpoint_auth_method: "client_secret_post" while your metadata only advertises "none". Respond with token_endpoint_auth_method: "none" in the registration response so the connector follows your public client model.

  • Run an origin check suitable for browser-posted registration if your API is exposed to the web.

For each redirect_uri, upsert a DynamoDB (or equivalent) row keyed by that URI with optional clientName and a TTL. Use a multi-day TTL (for example 30 days) because many connectors cache client_id and skip re-registration on reconnect. The OAuth client_id in the response should be your Cognito app client ID from configuration, the same value for every registration. Return HTTP 201 Created with a body that includes client_id, client_id_issued_at, echoed redirect_uris, chosen grant_types and response_types, default scope if the client omitted it, and token_endpoint_auth_method: "none".

Example registration request:

{
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "client_secret_post",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "openid email phone profile",
  "client_name": "Claude"
}
{
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "client_secret_post",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "openid email phone profile",
  "client_name": "Claude"
}
{
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "client_secret_post",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "openid email phone profile",
  "client_name": "Claude"
}
{
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "token_endpoint_auth_method": "client_secret_post",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "openid email phone profile",
  "client_name": "Claude"
}

Example normalized response:

{
  "client_id": "your_cognito_oauth_client_id",
  "client_id_issued_at": 1774026058,
  "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "openid profile email",
  "token_endpoint_auth_method": "none"
}
{
  "client_id": "your_cognito_oauth_client_id",
  "client_id_issued_at": 1774026058,
  "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "openid profile email",
  "token_endpoint_auth_method": "none"
}
{
  "client_id": "your_cognito_oauth_client_id",
  "client_id_issued_at": 1774026058,
  "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "openid profile email",
  "token_endpoint_auth_method": "none"
}
{
  "client_id": "your_cognito_oauth_client_id",
  "client_id_issued_at": 1774026058,
  "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "openid profile email",
  "token_endpoint_auth_method": "none"
}

One stable client_id keeps authorize and token handlers simple: they only accept that Cognito client ID (plus any legacy IDs you still allow). Without caps, TTL, and origin checks, registration turns into an open door for abuse.

Step three: consent screen

From the user side, this is the page they actually see after clicking authenticate in the connector. They're deciding whether to grant access, so this step is your trust surface, not window dressing.

Implement GET /oauth/client-info?redirect_uri=<encoded> for your consent page. Resolve the same redirect_uri you stored at registration. If a row exists, return clientName for display. If not, return a fallback label and still show the hostname parsed from redirect_uri so the user sees where the code will be sent.

On the consent screen, show the app name and the redirect host together. A friendly name alone isn't proof of identity. If the label says "Claude Desktop" but the redirect host is scammer-login-example.com, that mismatch should be obvious.

Display data doesn't replace authorization checks. Bind codes and tokens to the same redirect_uri and client_id you validated in Step four and Step five.

This is also where Step three and Step four connect in practice: the consent screen is hosted by your web app, and the "Grant access" action on that same screen submits to your authorize endpoint. In other words, Step three renders trusted context, and Step four turns that user decision into an authorization code.

Step four: authorization code

From the browser flow, this runs when the user presses your "Grant access" button on the consent screen from Step three. Your API creates the authorization code, then returns or performs a redirect to the connector callback URL that was provided as redirect_uri.

Implement POST /oauth/authorize (or your routed variant) behind your normal user session. API Gateway JWT authorizers work well here: the handler receives the authenticated user from the token Cognito already issued to your web app.

Query parameters the client sends typically include client_id, redirect_uri, response_type=code, scope, state, code_challenge, and code_challenge_method. Reject missing PKCE and require code_challenge_method of S256 only. Compare client_id to the same Cognito OAuth app client ID you returned from registration. Generate a random authorization code, store a row with userId, clientId, redirectUri, scope, codeChallenge, codeChallengeMethod, and a TTL (for example ten minutes). Include in scope any server-side resource identifier your MCP layer needs (for example a workspace or tenant id) so the access token exchange can scope MCP calls.

Return JSON your web app uses to redirect the browser, for example { "data": { "redirectUrl": "https://connector/callback?code=...&state=..." } }, or issue an HTTP redirect if that fits your frontend.

Example authorize redirect result: https://client-callback.example/callback?code=abc123&state=xyz

Step five: token exchange

From the connector side, this happens after the browser redirects back to its callback URL with code and optional state. The connector now calls your /oauth/token endpoint directly and exchanges that code for bearer tokens it will attach to MCP requests.

Implement POST /oauth/token so it accepts application/x-www-form-urlencoded (common for OAuth) and JSON, because clients differ. If API Gateway base64-encodes the body, decode before parsing. Reject unsupported grant_type values. Support authorization_code and refresh_token.

For authorization_code:

  1. Require code, redirect_uri, client_id, and code_verifier.

  2. Require client_id to equal your configured Cognito OAuth app client ID.

  3. Load the authorization code row. If missing or past TTL, return an error and delete expired rows when you detect them.

  4. Compare redirect_uri to the stored value with an exact string match.

  5. Compute SHA-256(code_verifier), encode as Base64 URL without padding, and compare to the stored code_challenge from the authorize step.

After validation, exchange for Cognito tokens. One production pattern is to derive a short-lived service password from a secret in AWS Secrets Manager and the user identifier, set it with Cognito admin APIs, then call AdminInitiateAuth with the OAuth app client ID and password flow, and return Cognito’s access_token, id_token, refresh_token, and expires_in. Delete the authorization code row after a successful exchange so codes are one-time use. For refresh_token, call Cognito’s refresh flow with the same OAuth client_id and validate the refresh token.

OAuth error responses should use the shapes clients expect (error, error_description) with appropriate HTTP status codes.

Example success body:

{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "eyJ...",
  "id_token": "eyJ...",
  "scope": "openid profile email"
}
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "eyJ...",
  "id_token": "eyJ...",
  "scope": "openid profile email"
}
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "eyJ...",
  "id_token": "eyJ...",
  "scope": "openid profile email"
}
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "eyJ...",
  "id_token": "eyJ...",
  "scope": "openid profile email"
}

Cognito and token separation

Use one user pool and two app clients. Cognito’s app client is the OAuth client_id that ends up inside tokens and in JWT validation. If your product already has an app client for the web or mobile app (passwordless OTP, hosted UI, Google, and similar), add a second app client used only for the MCP OAuth path. Name them so operators never confuse them, for example myapp-web and myapp-mcp-oauth.

What each client is for.

  • Web or primary app client. Configure it for how users sign in today: ALLOW_CUSTOM_AUTH, ALLOW_USER_SRP_AUTH, optional identity providers, and callback and logout URLs for your SPA. Tokens from this client are meant for your API routes and browser session, not for MCP JSON-RPC.

  • MCP OAuth client. Configure it for server-driven token issuance only: for example ALLOW_ADMIN_USER_PASSWORD_AUTH for a backend-only exchange path and ALLOW_REFRESH_TOKEN_AUTH for refresh. Point dynamic registration and your /oauth/token handler at this client ID only. Keep callback URLs off this client if MCP never uses a browser redirect to Cognito hosted UI for that client.

Why two clients stop cross-use. Cognito access tokens include a client_id claim tied to the app client that minted the token. That's what lets you reject the wrong token class without custom cryptography beyond JWT verification.

Validate MCP bearer tokens in your MCP handler with CognitoJwtVerifier. In Node, aws-jwt-verify exposes CognitoJwtVerifier.create with at least:

  • userPoolId: your pool id from configuration.

  • tokenUse: "access" so you only accept access tokens, not ID tokens, for API-style authorization.

  • clientId: your MCP OAuth app client id (the same value you return from /oauth/register and require at /oauth/token).

At request time, read Authorization: Bearer <jwt>, strip the prefix, and call verify on the token string. If a user pastes a normal web-session access token minted for your primary app client, verification fails because the token’s client_id doesn't match the verifier’s clientId. If verification succeeds, use sub as the stable user id and load MCP consent or workspace scope from your database as you already do.

Keep the verifier singleton-scoped in the Lambda execution environment so you don't rebuild JWKS clients on every invoke.

In this repository, the MCP handler constructs that verifier with the OAUTH_USER_POOL_CLIENT_ID environment variable (see services/mcp/handlers/mcp-server.ts). The shared HTTP API JWT authorizer in config/api-gateway/authorizer.yml sets audience to the CloudFormation output for the primary web UserPoolClientId, and services/auth/serverless.yml attaches it to routes such as POST /oauth/authorize/{friendlyWorkspaceId} where the caller is already signed in through the web client.

Validate regular user tokens at the API Gateway with a JWT authorizer. Routes that represent your signed-in product user (for example submitting consent or calling “my APIs”) should use an HTTP API JWT authorizer whose audience is your web app client id, not the MCP OAuth client id. A typical Serverless-style fragment looks like this:

  • issuerUrl: https://cognito-idp.<region>.amazonaws.com/<userPoolId> (same issuer as in Cognito’s OpenID document).

  • identitySource: $request.header.Authorization.

  • audience: list containing only UserPoolClientId for the web or primary client.

That authorizer accepts tokens users get from passwordless login, SRP, or hosted UI against the primary client. It doesn't accept MCP connector tokens, because those tokens carry the MCP OAuth client_id, so the audience check fails at the gateway.

How this splits traffic in practice.

  • MCP JSON-RPC route: validate in Lambda with CognitoJwtVerifier pinned to the MCP OAuth clientId. Optionally leave the route open at gateway level and enforce in code, or add a second JWT authorizer whose audience is the MCP OAuth client id if your platform supports distinct authorizers per route.

  • OAuth authorize or other “logged-in web user” routes: attach the shared Cognito JWT authorizer with audience = web UserPoolClientId, then your handler trusts API Gateway’s identity context.

The same user may hold a web-session token and an MCP connector token. Only the latter passes MCP verification, and only the former passes the gateway authorizer tuned to the web client.

Tools and business logic

OAuth gets you past the front door. Tools are where your product behavior lives: the model chooses a tool name, passes arguments, and your server runs domain code with the workspace and user context you resolved after JWT verification.

Use the official TypeScript SDK package @modelcontextprotocol/sdk (repository). It gives you McpServer, registerTool, schema-typed inputs, and interoperates with Streamable HTTP transports so you don't hand-roll JSON-RPC for tools/list and tools/call.

Factory pattern. Build one McpServer instance per request (or reuse carefully with request-scoped context) and pass in a small context object: at minimum userId and tenant or workspace id from your consent record, plus any feature flags (for example documents access). Register tools only when the context allows them so tools/list matches what the caller may actually invoke.

Descriptions are part of the API. Connectors send your tool definitions to the model. Write description fields for humans and models: what the tool does, when to call it, when not to call it, and how it relates to other tools (for example “call list_x first to obtain ids”). Long, explicit descriptions cut bad tool choices more than clever code ever will.

Input schema. Define arguments with a schema library the SDK supports (commonly Zod). Use .describe() on each field so clients surface helpful parameter docs. Keep names stable across releases so stored prompts and client caches don't break.

Annotations. Use MCP tool annotations where your SDK version exposes them: readOnlyHint, idempotentHint, openWorldHint, and a short title for UI chips. They help hosts rank and label tools without changing your handler code.

Handlers stay thin. Validate inputs, call your existing application services (same ones your REST API uses), map results to MCP content blocks (usually text JSON or markdown). Centralize authorization in one place: if JWT and consent already scoped the user to a workspace, still re-check row-level access inside the service layer.

Errors. Return structured error payloads in tool results where possible, and set isError when the invocation failed so the client can distinguish validation errors from transport failures. Include a machine-readable code and a nextAction string when another tool fixes the situation (for example “call list voices first”).

Naming. Keep tool names stable and namespaced if you expose both public and internal tools (for example list_templates versus admin_purge_cache). Avoid renaming without a version bump on your MCP server product version.

Observability. Log tool name, workspace id, and request id on each invocation. Tools are the natural place to attach usage metering or abuse alerts after auth.

Useful notes

Testing. Use MCP Inspector first (npx @modelcontextprotocol/inspector, Streamable HTTP, your MCP base URL) so discovery, registration, token, and JSON-RPC (initialize, tools/list) are exercised without a specific host’s quirks. Then try at least two connector families you care about (for example Claude web vs desktop vs Cursor). One client can pass while another fails when metadata, proxies, or client bugs differ, so don't ship on a single host alone.

Cloudflare. When OAuth looks successful but a browser connector still shows disconnected, check the edge before you rewrite Lambda. A common pattern is DNS-only for MCP and auth hostnames, retest the full flow, then re-enable WAF or rate limits in small steps so OAuth and MCP traffic still behaves like a direct hit to API Gateway.

DCR and CIMD. Most connectors still POST to registration_endpoint and use a string client_id. Client ID Metadata Documents (CIMD) are emerging (client_id as an HTTPS URL, metadata fetched instead of a registration write). If you keep token and registration logic separate from “how we show the app name in consent,” you can adopt CIMD later without redoing the OAuth exchange.

Share this article