Guide · AWS Cognito · OAuth2 Flows

OAuth2 and OIDC Flows for MCP Servers — Authorization Code with PKCE and Client Credentials

MCP server authentication requires two distinct OAuth2 flows depending on who is doing the authenticating. Human users connecting via Claude Desktop or a browser-based agent need the Authorization Code flow with PKCE — a browser redirect sequence that ends with a short-lived authorization code that is safely exchanged for tokens without ever exposing a client secret. Automated pipelines — one MCP server calling another, an orchestrator acquiring a service token, or a monitoring agent — use the Client Credentials flow, which exchanges a client ID and secret directly for an access token with no user interaction. Cognito provides both flows through its Hosted UI OAuth2 endpoints without any additional infrastructure. This guide implements both flows end-to-end with curl examples, explains Cognito's OAuth2 endpoint set, covers PKCE mechanics in detail, and documents scope configuration for resource servers. For the user pool and app client setup that this guide depends on see Cognito User Pools for MCP Servers. For verifying the tokens this flow produces see Verifying Cognito JWTs in MCP Servers.

TL;DR

For human users: generate a random code_verifier, hash it to code_challenge, redirect to /oauth2/authorize with code_challenge, receive the authorization code at your callback URL, exchange it at /oauth2/token with the original code_verifier. For M2M: POST to /oauth2/token with grant_type=client_credentials, client_id, client_secret, and scope. Verify received tokens using the JWKS endpoint — see JWT verification guide. For user pool and app client prerequisites see Cognito User Pools guide.

Flow comparison

PropertyAuthorization Code + PKCEClient Credentials
Who uses itHuman users — Claude Desktop, browser MCP clients, native appsMachines — MCP server to MCP server, pipelines, monitoring agents, CI/CD
User interactionRequired — user sees Hosted UI login screen and consentsNone — fully automated, no login screen
Client secret requiredNo — PKCE replaces the secret; use public clients without a secretYes — must be a confidential client with a Cognito-generated client secret
Tokens returnedaccess_token, id_token, refresh_tokenaccess_token only (no id_token, no refresh_token)
Refresh tokenYes — use to obtain new access tokens silentlyNo — re-request a new token when the current one expires
Token subject (sub)User's sub UUID from the User PoolApp client ID (not a user)
OIDC scopes (openid, email, profile)Supported — returns id_token with user claimsNot supported — these scopes require a user context
Custom resource server scopesSupportedSupported — M2M tokens carry resource server scopes
Cognito app client typePublic (no secret) or confidentialConfidential only (secret required)

Cognito OAuth2 endpoints

Cognito's Hosted UI exposes a complete set of OAuth2 and OIDC endpoints at your domain. All endpoints require HTTPS.

# All Cognito OAuth2 endpoints — replace {domain} with your Cognito domain
# Cognito-managed domain:
COGNITO_DOMAIN="https://mcp-server-example.auth.us-east-1.amazoncognito.com"
# Or custom domain:
COGNITO_DOMAIN="https://auth.mcp-server.example.com"

# OAuth2 / OIDC endpoints:
# ${COGNITO_DOMAIN}/oauth2/authorize   — start auth code flow (GET redirect)
# ${COGNITO_DOMAIN}/oauth2/token       — exchange code or client credentials (POST)
# ${COGNITO_DOMAIN}/oauth2/userInfo    — get user profile from access token (GET)
# ${COGNITO_DOMAIN}/oauth2/revoke      — revoke refresh token (POST)
# ${COGNITO_DOMAIN}/oauth2/logout      — invalidate session and redirect (GET)

# OIDC discovery document (metadata about the authorization server)
curl -s "${COGNITO_DOMAIN}/.well-known/openid-configuration" | jq '{
  issuer,
  authorization_endpoint,
  token_endpoint,
  userinfo_endpoint,
  jwks_uri,
  scopes_supported,
  response_types_supported,
  grant_types_supported,
  subject_types_supported,
  id_token_signing_alg_values_supported,
  code_challenge_methods_supported
}'

Authorization Code + PKCE flow

PKCE (Proof Key for Code Exchange, RFC 7636) prevents authorization code interception attacks. It works by binding the authorization request to the token exchange: the client generates a random code_verifier, includes its SHA-256 hash (code_challenge) in the authorization request, and then proves it knows the original verifier at the token exchange step. An attacker who intercepts the authorization code cannot exchange it without the code_verifier.

# Step 1: Generate PKCE values
# code_verifier: cryptographically random string, 43-128 characters, URL-safe characters only
# [A-Z] [a-z] [0-9] [-._~]  — no + or / from base64, no = padding
CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '=\n' | tr '+/' '-_' | cut -c1-128)
echo "code_verifier: $CODE_VERIFIER"
# e.g. "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

# code_challenge: BASE64URL(SHA256(ASCII(code_verifier)))
# Important: SHA256 on the raw ASCII bytes, then base64url-encode the binary hash (not hex)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=\n')
echo "code_challenge: $CODE_CHALLENGE"
# e.g. "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

# state: random value to prevent CSRF — must match what you get back in the callback
STATE=$(openssl rand -hex 16)

# Step 2: Build the authorization URL and redirect the user to it
CLIENT_ID="abcdefg1234567890"
REDIRECT_URI="https://mcp-server.example.com/callback"
SCOPES="openid+email+profile+https%3A%2F%2Fapi.mcp-server.example.com%2Ftools%3Ainvoke"

AUTH_URL="${COGNITO_DOMAIN}/oauth2/authorize\
?response_type=code\
&client_id=${CLIENT_ID}\
&redirect_uri=${REDIRECT_URI}\
&scope=${SCOPES}\
&code_challenge=${CODE_CHALLENGE}\
&code_challenge_method=S256\
&state=${STATE}"

echo "Redirect user to: $AUTH_URL"
# User logs in via Hosted UI, consents, and is redirected back to:
# https://mcp-server.example.com/callback?code=AUTH_CODE_HERE&state=STATE_HERE
# Step 3: Handle the callback — verify state, extract code, exchange for tokens
# After the user is redirected back, your server receives:
# GET /callback?code=AUTH_CODE_HERE&state=STATE_HERE

# Verify state matches what you stored in session (CSRF protection)
# Then exchange the code for tokens

AUTH_CODE="AUTH_CODE_HERE"  # extracted from callback URL query parameter

curl -s -X POST "${COGNITO_DOMAIN}/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code\
&client_id=${CLIENT_ID}\
&redirect_uri=${REDIRECT_URI}\
&code=${AUTH_CODE}\
&code_verifier=${CODE_VERIFIER}"
# Returns:
# {
#   "access_token": "eyJra...",       (60 min validity — use for API calls)
#   "id_token": "eyJra...",           (60 min validity — use for user identity)
#   "refresh_token": "eyJjb...",      (30 day validity — store securely)
#   "expires_in": 3600,
#   "token_type": "Bearer"
# }
# Step 4: Refresh the access token when it expires
# Use the refresh token to get new access + id tokens without user re-login

REFRESH_TOKEN="eyJjb..."

curl -s -X POST "${COGNITO_DOMAIN}/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token\
&client_id=${CLIENT_ID}\
&refresh_token=${REFRESH_TOKEN}"
# Returns new access_token and id_token (no new refresh_token)
# Refresh tokens are rotated only if token revocation is enabled and you re-authenticate

# Revoke a refresh token on logout (prevents further token issuance)
curl -s -X POST "${COGNITO_DOMAIN}/oauth2/revoke" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=${REFRESH_TOKEN}&client_id=${CLIENT_ID}"

PKCE implementation in Node.js

The following is a complete PKCE implementation for a Node.js/Express MCP server acting as the OAuth2 client (e.g., a server-side MCP integration that authenticates users before proxying requests to Cognito-protected upstream services).

// pkce.js — PKCE utilities
const crypto = require('crypto');

function generateCodeVerifier() {
  // 43-128 characters from the URL-safe base64 alphabet without padding
  return crypto.randomBytes(96)
    .toString('base64url') // Node.js 16+ supports base64url directly
    .slice(0, 128);
}

function generateCodeChallenge(verifier) {
  // S256 method: BASE64URL(SHA256(ASCII(code_verifier)))
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url'); // direct base64url output, no padding
}

function generateState() {
  return crypto.randomBytes(16).toString('hex');
}

module.exports = { generateCodeVerifier, generateCodeChallenge, generateState };
// auth-routes.js — OAuth2 routes for MCP server
const express = require('express');
const session = require('express-session');
const { generateCodeVerifier, generateCodeChallenge, generateState } = require('./pkce');

const router = express.Router();

const COGNITO_DOMAIN = process.env.COGNITO_DOMAIN;
const CLIENT_ID = process.env.COGNITO_CLIENT_ID;
const REDIRECT_URI = process.env.OAUTH2_REDIRECT_URI;
const SCOPES = [
  'openid', 'email', 'profile',
  'https://api.mcp-server.example.com/tools:invoke',
].join(' ');

// GET /auth/login — initiate PKCE flow
router.get('/login', (req, res) => {
  const verifier = generateCodeVerifier();
  const challenge = generateCodeChallenge(verifier);
  const state = generateState();

  // Store verifier and state in session (server-side only — never expose verifier to client)
  req.session.pkce = { verifier, state, returnTo: req.query.returnTo || '/' };

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: SCOPES,
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state,
  });

  res.redirect(`${COGNITO_DOMAIN}/oauth2/authorize?${params}`);
});

// GET /auth/callback — handle authorization code callback
router.get('/callback', async (req, res) => {
  const { code, state, error, error_description } = req.query;

  if (error) {
    return res.status(400).json({ error, error_description });
  }

  const pkce = req.session.pkce;
  if (!pkce || pkce.state !== state) {
    return res.status(400).json({ error: 'invalid_state', error_description: 'State mismatch — possible CSRF' });
  }

  try {
    const tokenResponse = await fetch(`${COGNITO_DOMAIN}/oauth2/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        code,
        code_verifier: pkce.verifier,
      }),
    });

    if (!tokenResponse.ok) {
      const err = await tokenResponse.json();
      throw new Error(err.error_description || err.error);
    }

    const tokens = await tokenResponse.json();

    // Store tokens in session (server-side; never expose refresh_token to client JS)
    req.session.tokens = {
      accessToken: tokens.access_token,
      idToken: tokens.id_token,
      refreshToken: tokens.refresh_token,
      expiresAt: Date.now() + (tokens.expires_in * 1000),
    };
    delete req.session.pkce;

    res.redirect(pkce.returnTo || '/');
  } catch (err) {
    res.status(500).json({ error: 'token_exchange_failed', error_description: err.message });
  }
});

module.exports = router;

Client Credentials flow for M2M

The client credentials flow is for machine-to-machine authentication where there is no user. The client authenticates directly with its credentials (client ID + secret) and receives an access token. Cognito requires the app client to have a client secret configured and to list client_credentials in AllowedOAuthFlows. The returned access token has no user sub — the sub claim equals the client ID.

# Client credentials flow — M2M token acquisition
# Used for: MCP server to MCP server, pipeline orchestrator tokens, monitoring agents

CONFIDENTIAL_CLIENT_ID="zyxwvuts9876543210"
CONFIDENTIAL_CLIENT_SECRET="your-client-secret-from-cognito"

# Encode credentials as Basic auth (base64 of "client_id:client_secret")
CREDENTIALS=$(echo -n "${CONFIDENTIAL_CLIENT_ID}:${CONFIDENTIAL_CLIENT_SECRET}" | base64)

# Option 1: Basic auth header (recommended)
curl -s -X POST "${COGNITO_DOMAIN}/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Authorization: Basic ${CREDENTIALS}" \
  -d "grant_type=client_credentials\
&scope=https%3A%2F%2Fapi.mcp-server.example.com%2Ftools%3Ainvoke"
# Returns:
# {
#   "access_token": "eyJra...",
#   "expires_in": 3600,
#   "token_type": "Bearer"
# }
# Note: NO id_token, NO refresh_token — client credentials tokens cannot be refreshed
# Just re-request a new token when the current one expires

# Option 2: Credentials in request body (less preferred but also supported)
curl -s -X POST "${COGNITO_DOMAIN}/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials\
&client_id=${CONFIDENTIAL_CLIENT_ID}\
&client_secret=${CONFIDENTIAL_CLIENT_SECRET}\
&scope=https%3A%2F%2Fapi.mcp-server.example.com%2Ftools%3Ainvoke"
// Node.js M2M token manager with automatic refresh before expiry
class CognitoM2MTokenManager {
  constructor({ cognitoDomain, clientId, clientSecret, scope }) {
    this.tokenEndpoint = `${cognitoDomain}/oauth2/token`;
    this.credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
    this.scope = scope;
    this.cachedToken = null;
    this.tokenExpiresAt = 0;
  }

  async getAccessToken() {
    // Refresh 60 seconds before actual expiry to avoid races
    const bufferMs = 60 * 1000;
    if (this.cachedToken && Date.now() < this.tokenExpiresAt - bufferMs) {
      return this.cachedToken;
    }
    return this.fetchNewToken();
  }

  async fetchNewToken() {
    const response = await fetch(this.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': `Basic ${this.credentials}`,
      },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        scope: this.scope,
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`M2M token fetch failed: ${error.error} — ${error.error_description}`);
    }

    const data = await response.json();
    this.cachedToken = data.access_token;
    this.tokenExpiresAt = Date.now() + (data.expires_in * 1000);
    return this.cachedToken;
  }
}

// Usage in MCP server that calls another Cognito-protected MCP server
const upstreamTokenManager = new CognitoM2MTokenManager({
  cognitoDomain: process.env.COGNITO_DOMAIN,
  clientId: process.env.M2M_CLIENT_ID,
  clientSecret: process.env.M2M_CLIENT_SECRET,
  scope: 'https://api.upstream-mcp-server.example.com/tools:invoke',
});

async function callUpstreamMCPServer(toolName, args) {
  const token = await upstreamTokenManager.getAccessToken();
  return fetch('https://api.upstream-mcp-server.example.com/mcp/tools/invoke', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ toolName, args }),
  });
}

Scopes: OIDC scopes vs resource server scopes

Cognito supports two categories of scopes. OIDC standard scopes (openid, email, profile, phone, address) control what claims are included in the ID token and what data the /oauth2/userInfo endpoint returns. Resource server scopes are custom scopes you define on a Cognito resource server — they appear in the access token's scope claim and are used for API authorization.

# Resource server scopes are prefixed with the resource server identifier
# Format: {resource-server-identifier}/{scope-name}
# Example: https://api.mcp-server.example.com/tools:invoke

# OIDC scopes are simple strings (no prefix)
# openid     — required to get an id_token; adds sub claim
# email      — adds email and email_verified claims to id_token
# profile    — adds name, given_name, family_name, picture, etc.
# phone      — adds phone_number and phone_number_verified claims
# aws.cognito.signin.user.admin — allows updating user attributes via API

# Requesting both OIDC and resource server scopes in the same authorization request:
SCOPES="openid+email+profile+https%3A%2F%2Fapi.mcp-server.example.com%2Ftools%3Ainvoke"

# The access token will contain the resource server scope:
# "scope": "openid email profile https://api.mcp-server.example.com/tools:invoke"

# The id_token will contain email, name, and profile claims (from OIDC scopes)
# Neither token includes custom user attributes unless a Pre-Token Generation
# Lambda trigger adds them — see: /seo/mcp-server-cognito-lambda-triggers

# Verify userInfo endpoint returns correct claims
ACCESS_TOKEN="eyJra..."
curl -s "${COGNITO_DOMAIN}/oauth2/userInfo" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"
# Returns claims from OIDC scopes (email, name, etc.) plus cognito:username and sub
# Add a scope to an existing resource server
aws cognito-idp update-resource-server \
  --user-pool-id $USER_POOL_ID \
  --identifier https://api.mcp-server.example.com \
  --name "MCP Server API" \
  --scopes '[
    {
      "ScopeName": "tools:invoke",
      "ScopeDescription": "Invoke MCP tools"
    },
    {
      "ScopeName": "tools:read",
      "ScopeDescription": "Read MCP tool definitions"
    },
    {
      "ScopeName": "admin",
      "ScopeDescription": "Administrative access to MCP server configuration"
    },
    {
      "ScopeName": "context:write",
      "ScopeDescription": "Write to MCP server context store"
    }
  ]'

# IMPORTANT: Adding a scope to the resource server does NOT automatically
# grant it to any app client. You must also update each client's AllowedOAuthScopes:
aws cognito-idp update-user-pool-client \
  --user-pool-id $USER_POOL_ID \
  --client-id $CLIENT_ID \
  --allowed-o-auth-scopes \
    openid email profile \
    "https://api.mcp-server.example.com/tools:invoke" \
    "https://api.mcp-server.example.com/tools:read" \
    "https://api.mcp-server.example.com/context:write"

UserInfo endpoint and logout

# Get user profile information using the access token
# Requires the access token (not id_token) and the "openid" scope was granted
ACCESS_TOKEN="eyJra..."
curl -s "${COGNITO_DOMAIN}/oauth2/userInfo" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"
# Returns:
# {
#   "sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
#   "cognito:username": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
#   "email_verified": "true",
#   "email": "user@example.com",
#   "name": "Jane Smith"   (if profile scope was granted)
# }

# Logout — invalidate the Hosted UI session and redirect
# post_logout_redirect_uri must be in the app client's LogoutURLs list
LOGOUT_URL="${COGNITO_DOMAIN}/logout\
?client_id=${CLIENT_ID}\
&logout_uri=https%3A%2F%2Fmcp-server.example.com%2Flogout"
echo "Redirect user to: $LOGOUT_URL"

# Combined: revoke refresh token AND redirect to logout
# Step 1: revoke the refresh token (invalidates all tokens derived from it)
curl -s -X POST "${COGNITO_DOMAIN}/oauth2/revoke" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=${REFRESH_TOKEN}&client_id=${CLIENT_ID}"

# Step 2: redirect user to logout URL to clear the Hosted UI session cookie
# Without this step, the user's browser still has a valid session cookie
# and the next /oauth2/authorize request may not prompt for login again

AliveMCP and OAuth2-protected MCP servers

The client credentials flow is exactly what AliveMCP uses to health-check OAuth2-protected MCP servers. When you configure your Cognito user pool's confidential app client credentials in AliveMCP, it fetches a fresh M2M access token before each health check probe — using the same /oauth2/token endpoint described in this guide — and includes it as a Bearer token in the HTTP request to your MCP server. AliveMCP tracks token expiry and re-fetches before the token lapses, ensuring health checks are never rejected due to token timeout.

AliveMCP also monitors the Cognito Hosted UI domain itself. If your auth.mcp-server.example.com custom domain returns errors or its TLS certificate expires, AliveMCP fires an alert before your users see login failures — because a broken auth domain is just as much an outage as a broken MCP server endpoint.

Failure modes reference

SymptomCauseFix
invalid_grant at token endpoint when exchanging codeAuthorization code already used (codes are single-use, 10-minute TTL); code_verifier does not match the code_challenge sent at authorization time; redirect_uri in the token request does not exactly match the one in the authorization requestEnsure the code exchange happens exactly once within 10 minutes; verify PKCE: re-derive code_challenge from code_verifier and compare to what was sent; ensure redirect_uri is identical in both requests (same protocol, path, no trailing slash difference)
invalid_client at token endpointClient secret sent for a public client (which has no secret); or wrong client secret for a confidential client; or client_id does not match the user poolFor public PKCE clients: do not send a client secret; for confidential clients: retrieve the current secret with describe-user-pool-client; never send a client secret from browser JavaScript
PKCE code_challenge rejected with invalid_requestSHA-256 hash computed as hex string instead of binary bytes before base64url-encoding; padding characters (=) left in the code_challenge; using plain method instead of S256Always hash the raw ASCII bytes of code_verifier with SHA-256, then base64url-encode the raw binary output (not the hex string); strip all = padding from the base64url output; always use code_challenge_method=S256
Client credentials token request returns unauthorized_clientApp client does not have client_credentials in AllowedOAuthFlows; or the requested scope is not in the client's AllowedOAuthScopes; or the scope does not exist on any resource serverUpdate app client with --allowed-o-auth-flows client_credentials; add the scope to both the resource server definition and the client's AllowedOAuthScopes
Requesting openid scope in client credentials flow returns errorOIDC scopes (openid, email, profile) require a user context — they cannot be granted to a machine client via client credentialsRemove OIDC scopes from client credentials requests; only request resource server scopes; if you need user identity in M2M context, use a service account user with the authorization code flow instead
State parameter missing or mismatched in callbackClient is not storing and verifying the state parameter; or state is stored in memory that is lost between the redirect-out and redirect-back (serverless cold start, horizontal scaling without shared session store)Store state in a server-side session backed by Redis or DynamoDB, not in process memory; verify req.query.state === req.session.pkce.state before proceeding with token exchange