Guide · AWS Cognito · User Pools

AWS Cognito User Pools for MCP Servers — Authentication, JWT Tokens, and Hosted UI

AWS Cognito User Pools provide a fully managed identity store for MCP server authentication: user registration, login, MFA, password policy enforcement, and JWT token issuance — all without running your own auth infrastructure. When a user authenticates, Cognito issues three tokens (access, ID, and refresh) that your MCP server validates locally using Cognito's published JWKS. The access token carries the scopes and groups needed for authorization; the ID token carries the user's profile claims; the refresh token lets clients obtain new short-lived access tokens without re-authentication. This guide covers creating the user pool, configuring app clients for both SPA/native clients (PKCE, no secret) and confidential server clients, setting up a Hosted UI domain, controlling token lifetimes, and understanding the JWT structure your MCP server will receive. For JWT validation middleware see Verifying Cognito JWTs in MCP Servers. For OAuth2 flows end-to-end see OAuth2 and OIDC Flows for MCP Servers.

TL;DR

Create a user pool with email as the username attribute, configure an app client with AllowedOAuthFlows: ["code"] and PKCE required (no client secret for SPA/native clients), attach a Cognito Hosted UI domain, and set token lifetimes to match your MCP server's session requirements. Your MCP server then validates the JWT access token against Cognito's JWKS endpoint on every request — see JWT verification guide. For federated AWS credentials (S3/DynamoDB from agent code), pair with Cognito Identity Pools. For custom claims and signup automation, add Lambda triggers.

Create the User Pool

The user pool is the user directory. Key decisions at creation time: which attributes users sign in with (email is the most common for MCP server deployments), whether attributes are mutable after creation, and which auth flows are allowed. These settings cannot all be changed after creation — in particular, UsernameAttributes and Schema are immutable.

# Create a user pool for MCP server authentication
# UsernameAttributes: email means users log in with their email, not a username
# ExplicitAuthFlows: allows SRP (secure remote password) and refresh token flows
aws cognito-idp create-user-pool \
  --pool-name mcp-server-users \
  --username-attributes email \
  --auto-verified-attributes email \
  --policies '{
    "PasswordPolicy": {
      "MinimumLength": 12,
      "RequireUppercase": true,
      "RequireLowercase": true,
      "RequireNumbers": true,
      "RequireSymbols": false,
      "TemporaryPasswordValidityDays": 7
    }
  }' \
  --mfa-configuration OPTIONAL \
  --user-pool-add-ons '{
    "AdvancedSecurityMode": "ENFORCED"
  }' \
  --account-recovery-setting '{
    "RecoveryMechanisms": [
      {"Priority": 1, "Name": "verified_email"}
    ]
  }' \
  --explicit-auth-flows \
    ALLOW_USER_SRP_AUTH \
    ALLOW_REFRESH_TOKEN_AUTH \
    ALLOW_USER_PASSWORD_AUTH \
  --deletion-protection ACTIVE \
  --tags Key=Service,Value=mcp-server Key=Environment,Value=production \
  --region us-east-1
# Output: { "UserPool": { "Id": "us-east-1_AbCdEfGhI", "Arn": "arn:aws:cognito-idp:..." } }

# Store the user pool ID for subsequent commands
USER_POOL_ID="us-east-1_AbCdEfGhI"

A few important notes on these settings. ALLOW_USER_PASSWORD_AUTH allows the password to be sent directly to Cognito from a server-side flow — useful for M2M or testing, but for user-facing login prefer ALLOW_USER_SRP_AUTH which never sends the password over the wire. AdvancedSecurityMode: ENFORCED enables Cognito's built-in compromise detection (risk scoring per sign-in) and is recommended for production.

# Add a custom attribute for MCP-specific metadata (must start with custom:)
# Custom attributes are read-only by users by default; developers can write them
aws cognito-idp add-custom-attributes \
  --user-pool-id $USER_POOL_ID \
  --custom-attributes '[
    {
      "Name": "plan",
      "AttributeDataType": "String",
      "Mutable": true,
      "Required": false,
      "StringAttributeConstraints": {
        "MinLength": "0",
        "MaxLength": "50"
      }
    },
    {
      "Name": "mcp_server_ids",
      "AttributeDataType": "String",
      "Mutable": true,
      "Required": false,
      "StringAttributeConstraints": {
        "MinLength": "0",
        "MaxLength": "2048"
      }
    }
  ]'
# Custom attributes appear in tokens as "custom:plan", "custom:mcp_server_ids"
# To inject them automatically into JWT tokens, use a Pre-Token Generation Lambda trigger
# See: /seo/mcp-server-cognito-lambda-triggers

Create a Resource Server (Scopes)

Before creating app clients, define the resource server and custom scopes that your MCP server will accept. Scopes are returned in the access token and used for fine-grained authorization. Cognito resource server scopes are formatted as resource-server-identifier/scope-name.

# Define the MCP server as a resource server with custom scopes
aws cognito-idp create-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"
    }
  ]'
# Resulting scope strings:
#   https://api.mcp-server.example.com/tools:invoke
#   https://api.mcp-server.example.com/tools:read
#   https://api.mcp-server.example.com/admin

Create App Clients

An app client represents a single application that authenticates users against your pool. For MCP server deployments you typically need two clients: one for the interactive user-facing flow (SPA or native app — no client secret, PKCE required) and one for server-to-server flows (confidential client with a client secret). See the OAuth2 flows guide for the full flow mechanics.

# App client for SPA / Claude Desktop / native MCP client
# CRITICAL: No GenerateSecret — public clients cannot keep a secret
# AllowedOAuthFlows: code only (not implicit — implicit flow is deprecated)
# ExplicitAuthFlows: must repeat pool-level flows here to enable for this client
aws cognito-idp create-user-pool-client \
  --user-pool-id $USER_POOL_ID \
  --client-name mcp-spa-client \
  --no-generate-secret \
  --allowed-o-auth-flows code \
  --allowed-o-auth-flows-user-pool-client \
  --allowed-o-auth-scopes \
    openid email profile \
    "https://api.mcp-server.example.com/tools:invoke" \
    "https://api.mcp-server.example.com/tools:read" \
  --callback-urls \
    "https://mcp-server.example.com/callback" \
    "http://localhost:3000/callback" \
  --logout-urls \
    "https://mcp-server.example.com/logout" \
    "http://localhost:3000/logout" \
  --supported-identity-providers COGNITO \
  --prevent-user-existence-errors ENABLED \
  --enable-token-revocation \
  --explicit-auth-flows \
    ALLOW_USER_SRP_AUTH \
    ALLOW_REFRESH_TOKEN_AUTH \
  --token-validity-units '{
    "AccessToken": "minutes",
    "IdToken": "minutes",
    "RefreshToken": "days"
  }' \
  --access-token-validity 60 \
  --id-token-validity 60 \
  --refresh-token-validity 30
# Output: { "UserPoolClient": { "ClientId": "abcdefg1234567890", ... } }

SPA_CLIENT_ID="abcdefg1234567890"
# App client for server-side / M2M flows (confidential client)
# GenerateSecret: Cognito generates a client secret
# Client credentials flow: grant_type=client_credentials for M2M
aws cognito-idp create-user-pool-client \
  --user-pool-id $USER_POOL_ID \
  --client-name mcp-server-confidential \
  --generate-secret \
  --allowed-o-auth-flows code client_credentials \
  --allowed-o-auth-flows-user-pool-client \
  --allowed-o-auth-scopes \
    "https://api.mcp-server.example.com/tools:invoke" \
    "https://api.mcp-server.example.com/tools:read" \
    "https://api.mcp-server.example.com/admin" \
  --callback-urls "https://backend.mcp-server.example.com/callback" \
  --supported-identity-providers COGNITO \
  --prevent-user-existence-errors ENABLED \
  --enable-token-revocation \
  --explicit-auth-flows \
    ALLOW_USER_SRP_AUTH \
    ALLOW_REFRESH_TOKEN_AUTH \
    ALLOW_USER_PASSWORD_AUTH \
  --token-validity-units '{
    "AccessToken": "minutes",
    "IdToken": "minutes",
    "RefreshToken": "days"
  }' \
  --access-token-validity 60 \
  --id-token-validity 60 \
  --refresh-token-validity 1

# Retrieve the generated client secret
aws cognito-idp describe-user-pool-client \
  --user-pool-id $USER_POOL_ID \
  --client-id $CONFIDENTIAL_CLIENT_ID \
  --query 'UserPoolClient.ClientSecret' \
  --output text

App client settings comparison

SettingPublic client (SPA/native)Confidential client (server-side)
Client secretNone — cannot be kept secret in browser or native appGenerated by Cognito; stored server-side in Secrets Manager
Auth flowsCode + PKCE; SRP; refresh tokenCode; client credentials; SRP; user password; refresh token
PKCE requirementRequired — replaces client secret for code exchangeOptional — client secret used instead, but PKCE still recommended
Client credentials grantNot supported — requires a client secretSupported — use for M2M tokens
Refresh token validityLonger is acceptable (users don't want to log in daily)Short — 1 day; M2M uses client credentials, not refresh tokens
Token revocationEnable — allows logout to invalidate refresh tokensEnable — server-side logout should revoke tokens
Typical use caseClaude Desktop, browser-based MCP clients, mobile appsServer-side MCP integrations, automated pipelines, admin tooling

Hosted UI Domain

The Hosted UI is Cognito's built-in OAuth2/OIDC-compliant login page. It handles the authorization code flow, login, signup, MFA challenge, and password reset — without you building any UI. For MCP server deployments, the Hosted UI is the fastest path to a working login flow.

# Option 1: Cognito-managed prefix domain (free, instant)
# Domain URL: https://{prefix}.auth.{region}.amazoncognito.com
aws cognito-idp create-user-pool-domain \
  --user-pool-id $USER_POOL_ID \
  --domain mcp-server-example
# Resulting domain: https://mcp-server-example.auth.us-east-1.amazoncognito.com

# Option 2: Custom domain (requires ACM certificate in us-east-1)
# First, request an ACM certificate for your auth subdomain
AUTH_CERT_ARN=$(aws acm request-certificate \
  --domain-name auth.mcp-server.example.com \
  --validation-method DNS \
  --region us-east-1 \
  --query 'CertificateArn' \
  --output text)

# Wait for validation, then create the custom domain
aws cognito-idp create-user-pool-domain \
  --user-pool-id $USER_POOL_ID \
  --domain auth.mcp-server.example.com \
  --custom-domain-config "CertificateArn=$AUTH_CERT_ARN"

# Get the CloudFront alias to set in your DNS (CNAME or ALIAS record)
aws cognito-idp describe-user-pool-domain \
  --domain auth.mcp-server.example.com \
  --query 'DomainDescription.CloudFrontDistribution'
# Add a CNAME: auth.mcp-server.example.com -> d1234abcd.cloudfront.net

Custom domains require an ACM certificate in us-east-1 regardless of which region your user pool is in — this is because Cognito's custom domain feature is backed by CloudFront, which always sources certificates from us-east-1. See the AWS ACM guide for certificate provisioning. The Hosted UI login URL with all parameters looks like this:

# Hosted UI authorization endpoint — redirect users here to start login
# Replace placeholders with your actual values
COGNITO_DOMAIN="https://mcp-server-example.auth.us-east-1.amazoncognito.com"
CLIENT_ID="abcdefg1234567890"
REDIRECT_URI="https://mcp-server.example.com/callback"

# For PKCE flow: generate code_verifier and code_challenge first
# See: /seo/mcp-server-cognito-oauth2 for full PKCE implementation
CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-128)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')

LOGIN_URL="${COGNITO_DOMAIN}/oauth2/authorize\
?response_type=code\
&client_id=${CLIENT_ID}\
&redirect_uri=${REDIRECT_URI}\
&scope=openid+email+profile+https%3A%2F%2Fapi.mcp-server.example.com%2Ftools%3Ainvoke\
&code_challenge=${CODE_CHALLENGE}\
&code_challenge_method=S256\
&state=$(openssl rand -hex 16)"

echo "Redirect user to: $LOGIN_URL"

Token lifetimes

Cognito issues three tokens per authentication. Their lifetimes are independently configurable per app client. Choosing appropriate lifetimes is a security and UX trade-off: shorter access tokens reduce the window for token abuse if a token is stolen; longer refresh tokens reduce how often users must re-authenticate.

TokenMinMaxDefaultRecommendation for MCP servers
Access token5 minutes1 day (1440 min)60 minutes60 minutes — short enough to limit abuse window, long enough to avoid excessive refresh calls during a work session
ID token5 minutes1 day (1440 min)60 minutesMatch access token lifetime — they are issued together and refreshed together
Refresh token1 day10 years (3650 days)30 days30 days for interactive users; 1 day for server-side clients (prefer client credentials for M2M)
# Update token validity on an existing app client
aws cognito-idp update-user-pool-client \
  --user-pool-id $USER_POOL_ID \
  --client-id $SPA_CLIENT_ID \
  --token-validity-units '{
    "AccessToken": "minutes",
    "IdToken": "minutes",
    "RefreshToken": "days"
  }' \
  --access-token-validity 60 \
  --id-token-validity 60 \
  --refresh-token-validity 30

# Verify current token validity settings
aws cognito-idp describe-user-pool-client \
  --user-pool-id $USER_POOL_ID \
  --client-id $SPA_CLIENT_ID \
  --query 'UserPoolClient.{AccessTokenValidity:AccessTokenValidity,IdTokenValidity:IdTokenValidity,RefreshTokenValidity:RefreshTokenValidity,Units:TokenValidityUnits}'

JWT structure and claims

Every JWT Cognito issues has three base64url-encoded parts separated by dots: header.payload.signature. The header identifies the signing algorithm (RS256) and the key ID (kid) used to sign the token. The payload contains the claims. The signature is an RSA signature using the private key corresponding to kid in the JWKS. Your MCP server fetches the JWKS to verify the signature — see Verifying Cognito JWTs for the full verification implementation.

# Decode a JWT to inspect its claims (without verification — for debugging only)
ACCESS_TOKEN="eyJra..."

# Decode header
echo $ACCESS_TOKEN | cut -d. -f1 | base64 -d 2>/dev/null | jq .
# { "kid": "abcdefghij1234567890", "alg": "RS256" }

# Decode payload
echo $ACCESS_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Access token payload example:
# {
#   "sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
#   "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_AbCdEfGhI",
#   "client_id": "abcdefg1234567890",
#   "origin_jti": "ffffffff-gggg-hhhh-iiii-jjjjjjjjjjjj",
#   "token_use": "access",
#   "scope": "openid email https://api.mcp-server.example.com/tools:invoke",
#   "auth_time": 1728432000,
#   "exp": 1728435600,
#   "iat": 1728432000,
#   "jti": "kkkkkkkk-llll-mmmm-nnnn-oooooooooooo",
#   "username": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
#   "cognito:groups": ["mcp-users", "plan-pro"]
# }

Critical claims your MCP server must check on every request:

# ID token payload differs from access token — compare the claims
# ID token example:
# {
#   "sub": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
#   "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_AbCdEfGhI",
#   "aud": "abcdefg1234567890",        # client_id is aud in ID token
#   "token_use": "id",                  # NOT "access"
#   "cognito:username": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
#   "cognito:groups": ["mcp-users"],
#   "email": "user@example.com",
#   "email_verified": true,
#   "auth_time": 1728432000,
#   "exp": 1728435600,
#   "iat": 1728432000,
#   "custom:plan": "pro",              # custom attributes (if pre-token generation trigger adds them)
#   "custom:mcp_server_ids": "srv-1,srv-2"
# }
# Use access token for API authorization; use ID token to get user profile info

MFA configuration

Cognito supports TOTP (software authenticators like Authy/Google Authenticator) and SMS MFA. For MCP server deployments where the end users are developers or enterprise customers, TOTP is the better choice — SMS MFA requires a Pinpoint or SNS configuration and incurs per-message costs.

# Configure TOTP MFA for the user pool
# MFA_CONFIGURATION: OFF, OPTIONAL, or ON
# OPTIONAL: users choose whether to enroll; ON: MFA required for all users
aws cognito-idp set-user-pool-mfa-config \
  --user-pool-id $USER_POOL_ID \
  --software-token-mfa-configuration Enabled=true \
  --mfa-configuration OPTIONAL

# Verify MFA configuration
aws cognito-idp get-user-pool-mfa-config \
  --user-pool-id $USER_POOL_ID

# For SMS MFA (requires SNS / Pinpoint IAM role):
aws cognito-idp set-user-pool-mfa-config \
  --user-pool-id $USER_POOL_ID \
  --sms-mfa-configuration '{
    "SmsAuthenticationMessage": "Your MCP server verification code is {####}",
    "SmsConfiguration": {
      "SnsCallerArn": "arn:aws:iam::123456789012:role/CognitoSNSRole",
      "ExternalId": "unique-external-id"
    }
  }' \
  --mfa-configuration OPTIONAL

AliveMCP and Cognito-protected MCP servers

Once your MCP server requires a Cognito access token on every request, traditional uptime monitors that issue a plain HTTP GET to your endpoint will start receiving 401 Unauthorized responses — and potentially report your server as down when it is actually healthy. AliveMCP handles authenticated MCP servers: you configure AliveMCP with an M2M client credentials token (rotated automatically) from your Cognito user pool's confidential app client, and AliveMCP attaches it as a Bearer token on health-check requests. AliveMCP distinguishes between authentication failures (your token configuration is broken — a different alert) and genuine server failures (5xx, timeout, or TCP unreachable — your server is down).

This means you can add Cognito authentication to your MCP server without giving up visibility into its uptime. AliveMCP also monitors the TLS certificate on your Cognito Hosted UI domain — if the ACM certificate backing your custom auth domain is about to expire, you get an alert before your login flow breaks.

Failure modes reference

SymptomCauseFix
NotAuthorizedException: Unable to verify secret hash for client from SPA clientApp client has a client secret configured but the SPA is not sending the secret hash (because SPAs shouldn't have secrets)Create a new app client with --no-generate-secret; or compute the SECRET_HASH = BASE64(HMAC-SHA256(username + clientId, clientSecret)) header — but the real fix is to use a public client without a secret for browser/native apps
Hosted UI returns HTTP 400 "redirect_mismatch" on login callbackThe redirect_uri in the authorization request does not exactly match one of the CallbackURLs registered on the app client (including trailing slashes, protocol, and case)Add the exact redirect URI to the app client's CallbackURLs via update-user-pool-client; check for trailing slash or http vs https mismatch
Token endpoint returns invalid_grant when exchanging auth codeAuthorization code already used (codes are single-use); code expired (10-minute TTL); PKCE code_verifier does not match the code_challenge sent at authorization timeEnsure the code exchange happens within 10 minutes; never retry a code exchange on failure (request a new code); verify PKCE implementation — see OAuth2 guide
Access token token_use claim is "id" instead of "access"Client sent the ID token to the MCP server API instead of the access tokenUse the access_token field from the token response (not id_token) when calling MCP server APIs; update JWT verification middleware to reject tokens where token_use != "access"
Custom domain returns 404 after setupCNAME DNS record pointing to the CloudFront distribution has not propagated yet, or was never addedRun aws cognito-idp describe-user-pool-domain --domain auth.mcp.example.com to get the CloudFront alias; add a CNAME record in DNS; wait for propagation (up to 48h for some providers)
User pool deletion fails with InvalidParameterException: Deletion protection enabledPool was created with --deletion-protection ACTIVEFirst run aws cognito-idp update-user-pool --user-pool-id $ID --deletion-protection INACTIVE, then delete