Guide · AWS Cognito · Hosted UI
AWS Cognito Hosted UI in MCP Servers — Authorization Code + PKCE, Token Endpoint, Custom Domain
MCP servers that expose browser-facing tools need a standards-compliant authentication UI without building one from scratch. AWS Cognito's Hosted UI is a fully managed OAuth2/OIDC login page — sign-in, sign-up, MFA challenge, and social provider federation — served from a Cognito-managed domain or your own custom domain. For public clients (browser extensions, desktop apps, SPA frontends), the authorization code flow with PKCE eliminates the need to store a client secret. The token endpoint then exchanges the short-lived authorization code for an ID token, access token, and refresh token. This guide covers provisioning the Hosted UI, wiring the authorization code + PKCE flow into an MCP-adjacent client, handling the token endpoint exchange server-side, and managing refresh token rotation so long-lived MCP sessions stay authenticated without re-prompting the user.
TL;DR
Enable the Hosted UI by creating a user pool domain (CreateUserPoolDomainCommand) and an app client with AllowedOAuthFlows: ['code'] and SupportedIdentityProviders: ['COGNITO']. Construct the authorization URL with response_type=code, code_challenge (S256 PKCE), and redirect_uri. After the user authenticates, Cognito redirects to your callback with a code parameter — POST it to the token endpoint (https://<domain>.auth.<region>.amazoncognito.com/oauth2/token) to exchange for tokens. Validate the ID token JWT locally with the user pool's JWKS URI before trusting any claims.
Provisioning the Hosted UI domain and app client
import {
CognitoIdentityProviderClient,
CreateUserPoolDomainCommand,
CreateUserPoolClientCommand,
OAuthFlowType,
} from '@aws-sdk/client-cognito-identity-provider';
const cognito = new CognitoIdentityProviderClient({ region: 'us-east-1' });
// Step 1: Create a Cognito-managed subdomain
await cognito.send(new CreateUserPoolDomainCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
Domain: 'auth-myapp', // becomes auth-myapp.auth.us-east-1.amazoncognito.com
}));
// Step 2: Create a public app client (no secret — PKCE replaces secret for public clients)
const { UserPoolClient } = await cognito.send(new CreateUserPoolClientCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
ClientName: 'mcp-web-client',
GenerateSecret: false, // public client — no secret stored in browser
AllowedOAuthFlows: [OAuthFlowType.code],
AllowedOAuthFlowsUserPoolClient: true,
AllowedOAuthScopes: ['openid', 'email', 'profile'],
SupportedIdentityProviders: ['COGNITO'],
CallbackURLs: [
'https://myapp.com/auth/callback',
'http://localhost:3000/auth/callback', // dev only — remove in production
],
LogoutURLs: ['https://myapp.com/logout'],
ExplicitAuthFlows: [], // disable username/password flows for hosted-UI-only client
EnableTokenRevocation: true,
PreventUserExistenceErrors: 'ENABLED',
TokenValidityUnits: {
AccessToken: 'hours',
IdToken: 'hours',
RefreshToken: 'days',
},
AccessTokenValidity: 1,
IdTokenValidity: 1,
RefreshTokenValidity: 30,
}));
console.log('Client ID:', UserPoolClient?.ClientId);
Setting GenerateSecret: false creates a public client appropriate for browser and native apps where a client secret cannot be kept confidential. The PKCE extension (added during the authorization request) compensates for the absence of a client secret by binding the authorization code to the code verifier that only the initiating client possesses.
The CallbackURLs list is an exact-match whitelist — Cognito rejects any redirect_uri parameter that does not appear verbatim in this list. Register every environment's callback URL, then remove development URLs before shipping to production by calling UpdateUserPoolClientCommand.
Building the PKCE authorization URL
import { randomBytes, createHash } from 'crypto';
function generatePkce(): { verifier: string; challenge: string } {
// Code verifier: 43-128 random URL-safe characters
const verifier = randomBytes(32)
.toString('base64url')
.slice(0, 64);
// Code challenge: BASE64URL(SHA256(verifier))
const challenge = createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
function buildAuthorizationUrl(params: {
domain: string;
region: string;
clientId: string;
redirectUri: string;
scopes: string[];
state: string;
codeChallenge: string;
}): string {
const base = `https://${params.domain}.auth.${params.region}.amazoncognito.com/oauth2/authorize`;
const query = new URLSearchParams({
response_type: 'code',
client_id: params.clientId,
redirect_uri: params.redirectUri,
scope: params.scopes.join(' '),
state: params.state, // CSRF protection — verify on callback
code_challenge: params.codeChallenge,
code_challenge_method: 'S256',
});
return `${base}?${query.toString()}`;
}
// Usage: store verifier + state in session before redirect
const { verifier, challenge } = generatePkce();
const state = randomBytes(16).toString('hex'); // opaque CSRF token
const authUrl = buildAuthorizationUrl({
domain: 'auth-myapp',
region: 'us-east-1',
clientId: 'YOUR_CLIENT_ID',
redirectUri: 'https://myapp.com/auth/callback',
scopes: ['openid', 'email', 'profile'],
state,
codeChallenge: challenge,
});
// Persist { verifier, state } in server-side session, then redirect user to authUrl
The state parameter prevents CSRF attacks: store it server-side before the redirect, then on the callback, compare the state query parameter against the stored value. Reject the callback if they don't match. The code_verifier must also be stored server-side (not in a cookie accessible to JavaScript) until the token exchange.
Use the login_hint parameter to pre-populate the email field if you already have the user's identifier: append &login_hint=user@example.com to the URL. This skips the email input step in the Hosted UI, reducing friction for returning users.
Token endpoint exchange
interface TokenResponse {
access_token: string;
id_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
}
async function exchangeCodeForTokens(params: {
domain: string;
region: string;
code: string;
codeVerifier: string;
clientId: string;
redirectUri: string;
}): Promise<TokenResponse> {
const tokenEndpoint =
`https://${params.domain}.auth.${params.region}.amazoncognito.com/oauth2/token`;
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: params.clientId,
code: params.code,
redirect_uri: params.redirectUri,
code_verifier: params.codeVerifier, // proves possession — replaces client secret
});
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${error}`);
}
return response.json() as Promise<TokenResponse>;
}
// Refresh token grant (when access token expires)
async function refreshAccessToken(params: {
domain: string;
region: string;
refreshToken: string;
clientId: string;
}): Promise<Omit<TokenResponse, 'refresh_token'>> {
const tokenEndpoint =
`https://${params.domain}.auth.${params.region}.amazoncognito.com/oauth2/token`;
const body = new URLSearchParams({
grant_type: 'refresh_token',
client_id: params.clientId,
refresh_token: params.refreshToken,
});
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) throw new Error(`Refresh failed: ${response.status}`);
return response.json();
}
The authorization code is single-use and short-lived (5 minutes). If the exchange fails, redirect the user through the authorization flow again — do not retry with the same code. The redirect_uri in the token exchange must be identical to the one used in the authorization request; Cognito verifies this as a second binding check alongside the code_verifier.
With EnableTokenRevocation: true on the app client, calling RevokeTokenCommand invalidates the refresh token and all access tokens issued from it. Wire this into your logout handler so Cognito-side session state is cleaned up, not just the local cookie.
Validating the ID token JWT
import { createPublicKey } from 'crypto';
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS_URI = 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX/.well-known/jwks.json';
const JWKS = createRemoteJWKSet(new URL(JWKS_URI));
interface CognitoClaims {
sub: string;
email: string;
email_verified: boolean;
'cognito:username': string;
'cognito:groups'?: string[];
iss: string;
aud: string;
exp: number;
iat: number;
token_use: 'id' | 'access';
}
async function validateIdToken(idToken: string, clientId: string): Promise<CognitoClaims> {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX',
audience: clientId, // aud claim must match the app client ID
});
if (payload.token_use !== 'id') {
throw new Error('Expected id token, got access token');
}
return payload as unknown as CognitoClaims;
}
Never trust claims from the ID token without verifying the JWT signature against the user pool's public JWKS. The JWKS endpoint is stable but keys rotate periodically — createRemoteJWKSet from the jose library handles key caching and automatic rotation. Hardcoding a key from the JWKS endpoint will break when Cognito rotates it.
Verify both iss (must be your user pool URL) and aud (must be your app client ID) to prevent token substitution attacks where a valid token from a different app client or user pool is presented to your service.
Custom domain setup
// Replace Cognito subdomain with your own domain (requires ACM certificate)
await cognito.send(new CreateUserPoolDomainCommand({
UserPoolId: 'us-east-1_XXXXXXXXX',
Domain: 'auth.myapp.com', // your custom domain — NOT a Cognito subdomain
CustomDomainConfig: {
// Certificate must be in us-east-1 regardless of user pool region
CertificateArn: 'arn:aws:acm:us-east-1:123456789012:certificate/...',
},
}));
// After creation, get the CloudFront distribution domain to create CNAME:
// aws cognito-idp describe-user-pool-domain --domain auth.myapp.com
// → AliasTarget (e.g., d111111abcdef8.cloudfront.net)
// Create a CNAME: auth.myapp.com → d111111abcdef8.cloudfront.net
Custom domains require an ACM certificate in us-east-1 even if your user pool is in another region — Cognito's Hosted UI runs on CloudFront, which is global and requires certificates from us-east-1. After CreateUserPoolDomainCommand, call DescribeUserPoolDomainCommand to get the CloudFrontDistribution value and create the DNS CNAME record pointing your custom domain to it.
Custom domain provisioning takes 15–30 minutes while CloudFront propagates. The hosted UI will return 403 during this window — plan for it in deployment runbooks.