Skip to content

Amazon Cognito M2M Token Management - Cost Optimization Through Caching

13 minute read
Content level: Advanced
0

Optimizing authentication costs when third-party partners can't implement token caching best practices

1. The Challenge

Third-party partners repeatedly request new Cognito M2M tokens for every API call, instead of reusing existing valid tokens. This practice leads to excessive authentication costs. We requested that they cache the tokens, but we lack the authority to influence their development.

Current Third-Party Integration Pattern

Current Third-Party Integration Pattern

The Integration Challenge: Partners request new tokens for every API call instead of implementing token reuse, resulting in excessive authentication costs.

Business Impact Analysis

Scenario: 3 third-party integrations, 10,000 API calls each/day
Current Cost: 3 partners × 10,000 token requests × 30 days × $0.00225 = $2,025/month

The Cost Problem: Partners request new tokens for every API call instead of reusing valid tokens (which last 1 hour), resulting in unnecessary authentication costs that could be reduced by 99.9% through proper token caching.

With Caching Solution:

  • Token requests needed: ~720/month (1 token per hour per partner)
  • Cognito cost: 720 × $0.00225 = $1.62/month
  • Monthly savings: $2,023.38 (99.9% reduction)

2. Solution Architecture & Implementation

Flow Diagram

Flow Diagram

Detailed Architecture Components

Detailed Architecture Components

Architecture Components

The solution uses serverless architecture with four main components. API Gateway receives partner requests and handles authentication before routing to Lambda. Lambda Token Service manages the complete caching flow by checking DynamoDB cache first, then retrieving credentials and requesting new tokens from Cognito only when needed. DynamoDB provides both partner configuration storage and token caching with automatic TTL cleanup after 1 hour. Amazon Cognito User Pool issues JWT tokens using client credentials grant flow.

Key Benefits: Fully managed caching with automatic cleanup, pay-per-request pricing, single-digit millisecond cache response times, and centralized authentication control.

Example Data in DynamoDB Tables

TokenCache Table Sample Data:

{
  "cache_key": "partner-abc_token",
  "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "partner_id": "partner-abc",
  "created_at": "2024-07-20T10:30:00Z",
  "ttl": 1721473800
}

PartnerConfig Table Sample Data:

{
  "partner_id": "partner-abc",
  "partner_name": "ABC Corporation",
  "cognito_client_id": "7a8b9c0d1e2f3g4h5i6j7k8l9m0n1o2p",
  "cognito_client_secret": "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789...",
  "user_pool_id": "us-east-1_AbCdEfGhI",
  "token_endpoint": "https://your-domain.auth.us-east-1.amazoncognito.com/oauth2/token",
  "scopes": ["read:api", "write:api"],
  "status": "active",
  "rate_limit": 1000
}

Sequence Diagram

First Request (Cache Miss)

First Request - Cache Miss

Subsequent Requests (Cache Hit)

Subsequent Requests - Cache Hit

3. Deployment Process

This solution can be deployed in under 30 minutes using the complete CloudFormation template provided in the Appendix. The template creates all necessary AWS resources with proper IAM permissions and configurations.

Prerequisites

  • AWS CLI configured with appropriate permissions

Quick Deployment Steps

  1. Download the CloudFormation template: cognito-m2m-cache-template.yaml

  2. Deploy the stack using AWS CLI:

aws cloudformation create-stack \
  --stack-name cognito-m2m-token-cache \
  --template-body file://cognito-m2m-cache-template.yaml \
  --capabilities CAPABILITY_IAM \
  --region us-east-1
  1. Wait for stack creation (approximately 5-10 minutes):
aws cloudformation wait stack-create-complete \
  --stack-name cognito-m2m-token-cache \
  --region us-east-1
  1. Get the stack outputs for configuration:
aws cloudformation describe-stacks \
  --stack-name cognito-m2m-token-cache \
  --query 'Stacks[0].Outputs' \
  --region us-east-1
  1. Create Cognito domain for OAuth endpoints (required for client credentials grant):
# Get User Pool ID from step 4 outputs
USER_POOL_ID=$(aws cloudformation describe-stacks \
  --stack-name cognito-m2m-token-cache \
  --query 'Stacks[0].Outputs[?OutputKey==`CognitoUserPoolId`].OutputValue' \
  --output text \
  --region us-east-1)

# Create domain with unique name
aws cognito-idp create-user-pool-domain \
  --user-pool-id $USER_POOL_ID \
  --domain "m2m-token-$(date +%Y%m%d%H%M)" \
  --region us-east-1
  1. Get Cognito client secret (needed for partner configuration):
# Get Client ID from step 4 outputs
CLIENT_ID=$(aws cloudformation describe-stacks \
  --stack-name cognito-m2m-token-cache \
  --query 'Stacks[0].Outputs[?OutputKey==`CognitoClientId`].OutputValue' \
  --output text \
  --region us-east-1)

# Get client secret
aws cognito-idp describe-user-pool-client \
  --user-pool-id $USER_POOL_ID \
  --client-id $CLIENT_ID \
  --query 'UserPoolClient.ClientSecret' \
  --output text \
  --region us-east-1
  1. Add partner configuration to DynamoDB:
# Set variables from previous steps
CLIENT_SECRET="your-client-secret-from-step-6"
DOMAIN_NAME="your-domain-from-step-5"

# Add partner configuration
aws dynamodb put-item \
  --table-name PartnerConfig \
  --item '{
    "partner_id": {"S": "test-partner-001"},
    "partner_name": {"S": "Test Partner Organization"},
    "cognito_client_id": {"S": "'$CLIENT_ID'"},
    "cognito_client_secret": {"S": "'$CLIENT_SECRET'"},
    "cognito_domain": {"S": "'$DOMAIN_NAME'"},
    "scopes": {"SS": ["token-service-api/read", "token-service-api/write", "token-service-api/cache"]},
    "status": {"S": "active"},
    "created_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}
  }' \
  --region us-east-1

Stack Outputs

After successful deployment, the CloudFormation stack provides these outputs:

  • TokenServiceUrl: API Gateway endpoint for token requests
  • CognitoUserPoolId: Created Cognito User Pool ID
  • CognitoClientId: Created Cognito Client ID
  • PartnerConfigTableName: DynamoDB table name for partner configuration

4. Testing and Validation

This section provides step-by-step validation to prove the token caching solution works correctly and delivers the expected cost optimization.

Test Setup

Before testing, ensure you have:

  • Deployed CloudFormation stack successfully
  • Created Cognito domain for OAuth endpoints
  • Added partner configuration to DynamoDB with real client credentials
  • Token Service URL from stack outputs

Test 1: Cache Miss (First Request)

Purpose: Validate that the first token request fetches from Cognito and stores in cache.

  1. Make the first token request:
# Use the Token Service URL from your stack outputs
curl -X GET "https://your-api-gateway-url/token?partner_id=test-partner-001" \
  -H "Content-Type: application/json"
  1. Expected Response (Real JWT Token):
{
  "access_token": "eyJraWQiOiJjcFBNXC9cLzQ1dEI0SThPVWxxNVByUlRUTllkSEJnU09vWUpCcXZCYkgxN1k9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiI3MHFrbTNmY3ExcHVpN204c2F1YzhvNGdmNCIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoidG9rZW4tc2VydmljZS1hcGlcL3JlYWQgdG9rZW4tc2VydmljZS1hcGlcL3dyaXRlIHRva2VuLXNlcnZpY2UtYXBpXC9jYWNoZSIsImF1dGhfdGltZSI6MTc1MzAzMzQ4MiwiaXNzIjoiaHR0cHM6XC9cL2NvZ25pdG8taWRwLnVzLWVhc3QtMS5hbWF6b25hd3MuY29tXC91cy1lYXN0LTFfR0RSNHVCWFJiIiwiZXhwIjoxNzUzMDM3MDgyLCJpYXQiOjE3NTMwMzM0ODIsInZlcnNpb24iOjIsImp0aSI6IjIxY2EzMTQwLTUzN2QtNDYyZS1hN2JmLWRiZjNmZjQwNzQ5ZSIsImNsaWVudF9pZCI6IjcwcWttM2ZjcTFwdWk3bThzYXVjOG80Z2Y0In0.WyZCA8L7cwZJ-bBDNBw9NYsczerX_sniXAtCnMmzYYEVoQ-6VX0gAIHe2oqpHefG0fOx9nYyaLcnyhTuEnJW00WMS03gHRhcCUH0gF1QcOzpsPYoYCiqSj3Mz9JXjEfZit4Agk1D9BBLsCj4Ke6wRygzdl8J0Ub6fdKB6z1tkaImPbee-houHGa0SRQm8sY3XUc29EwBDEzsUkfGLYjbCLvlc8cBZvifnN4ifhtCSzRdWxCVB07hsRHQs559G0gfaqz1p_Bxwg9upuYB9LCtAbBMebRXSfT3pByao8DY14pOwU6C1N3l_URDYqQ9gI--YTTo_9iU4dbx5-qUbe0L4w",
  "source": "cognito",
  "timestamp": "2025-07-20T17:44:42.770467"
}
  • Success Criteria**: Response shows "source": "cognito" and a real JWT token is returned and stored in DynamoDB cache.

JWT Token Structure

The returned access token is a valid JWT with the following structure when decoded:

{
  "sub": "70qkm3fcq1pui7m8sauc8o4gf4",
  "token_use": "access",
  "scope": "token-service-api/read token-service-api/write token-service-api/cache",
  "auth_time": 1753033482,
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_GDR4uBXRb",
  "exp": 1753037082,
  "iat": 1753033482,
  "version": 2,
  "jti": "21ca3140-537d-462e-a7bf-dbf3ff40749e",
  "client_id": "70qkm3fcq1pui7m8sauc8o4gf4"
}

Key JWT Claims:

  • scope: Contains the authorized scopes for the token
  • exp: Token expiration time (1 hour from issuance)
  • iss: Cognito User Pool issuer URL
  • client_id: The M2M client identifier

Test 2: Cache Hit (Subsequent Requests)

Purpose: Validate that subsequent requests return cached tokens without calling Cognito.

  1. Make the same token request immediately:
curl -X GET "https://your-api-gateway-url/token?partner_id=test-partner-001" \
  -H "Content-Type: application/json"
  1. Expected Response (Same JWT Token from Cache):
{
  "access_token": "[example-token]",
  "source": "cache",
  "timestamp": "2025-07-20T17:45:00.690223"
}
  • Success Criteria**: Response shows "source": "cache" and the exact same JWT token value as Test 1, proving the token was retrieved from DynamoDB cache instead of making a new Cognito request.

Test 3: Direct OAuth 2.0 Verification

Purpose: Verify that the OAuth 2.0 client credentials flow works directly with Cognito.

  1. Test direct Cognito authentication:
# Get credentials from your DynamoDB PartnerConfig table
CLIENT_ID="your-client-id"
CLIENT_SECRET="your-client-secret"
DOMAIN="your-cognito-domain"

curl -X POST \
  "https://$DOMAIN.auth.us-east-1.amazoncognito.com/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=client_credentials&scope=token-service-api/read token-service-api/write token-service-api/cache"
  1. Expected Response:
{
  "access_token": "[example-token]...",
  "expires_in": 3600,
  "token_type": "Bearer"
}
  • Success Criteria**: Valid JWT token returned with 1-hour expiration.

Test 4: Performance Validation

Purpose: Measure response time improvement with caching.

Steps: Measure cache miss vs cache hit response times using the time command with curl requests to the same endpoint.

# Measure cache miss (first request)
time curl -X GET "https://your-api-gateway-url/token?partner_id=test-partner-001"

# Measure cache hit (subsequent request)
time curl -X GET "https://your-api-gateway-url/token?partner_id=test-partner-001"
  • Success Criteria**: Cache hit should be significantly faster (typically 50-80% reduction in response time).

Expected Results Summary

Test ScenarioExpected OutcomePerformance ImpactExample Response Time
First Request (Cache Miss)"source": "cognito"Full Cognito authentication~1-2 seconds
Subsequent Requests (Cache Hit)"source": "cache"DynamoDB cache retrieval~200-500ms
Direct OAuth TestValid JWT with scopesBaseline Cognito performance~800ms-1.5s
Cost per 1000 API calls1 Cognito call + 999 cache hits99.9% cost reductionN/A

5. Conclusion

This centralized token caching solution addresses the cost optimization challenge when third-party partners don't implement proper token reuse practices.

Cost Impact Summary:

  • Before: 3 partners × 10,000 API calls/day × 30 days × $0.00225 = $2,025/month
  • After: Complete solution cost breakdown:
    • Cognito M2M tokens: 720 requests × $0.00225 = $1.62/month
    • API Gateway requests: 900,000 × $0.0000035 = $3.15/month
    • Lambda executions: 900,000 × $0.0000002 = $0.18/month
    • DynamoDB operations: ~1,800 reads × $0.00000025 = $0.0005/month
    • Total monthly cost: ~$5.00

Key Achievements:

  • $2,020/month savings (99.75% cost reduction)
  • Eliminates dependency on partner development teams
  • Provides centralized authentication control and monitoring
  • Maintains security best practices with 1-hour token expiration
  • Scales automatically with serverless architecture

6. Appendix: CloudFormation Template

The complete CloudFormation (US-EAST-1):

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Cognito M2M Token Caching Service - Fixed Configuration for Client Credentials Grant'

Resources:
  # Cognito User Pool for M2M authentication
  TestUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: 'cognito-m2m-test-pool'
      Policies:
        PasswordPolicy:
          MinimumLength: 8

  # Resource Server - Required for client credentials grant
  TokenServiceResourceServer:
    Type: AWS::Cognito::UserPoolResourceServer
    Properties:
      UserPoolId: !Ref TestUserPool
      Identifier: 'token-service-api'
      Name: 'Token Service API'
      Scopes:
        - ScopeName: 'read'
          ScopeDescription: 'Read access to token service'
        - ScopeName: 'write'
          ScopeDescription: 'Write access to token service'
        - ScopeName: 'cache'
          ScopeDescription: 'Access to token cache operations'

  # User Pool Client configured for M2M with client credentials grant
  TestUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    DependsOn: TokenServiceResourceServer
    Properties:
      UserPoolId: !Ref TestUserPool
      ClientName: 'test-m2m-client'
      GenerateSecret: true
      # Remove ExplicitAuthFlows - not needed for M2M
      # Use AllowedOAuthFlows for client credentials grant
      AllowedOAuthFlows:
        - client_credentials
      AllowedOAuthFlowsUserPoolClient: true
      AllowedOAuthScopes:
        - token-service-api/read
        - token-service-api/write
        - token-service-api/cache
      # Token validity settings
      AccessTokenValidity: 1
      TokenValidityUnits:
        AccessToken: hours

  # DynamoDB Tables with encryption
  TokenCacheTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: 'TokenCache'
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: cache_key
          AttributeType: S
      KeySchema:
        - AttributeName: cache_key
          KeyType: HASH
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true
      SSESpecification:
        SSEEnabled: true
        SSEType: KMS
        KMSMasterKeyId: alias/aws/dynamodb
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Purpose
          Value: TokenCaching
        - Key: Environment
          Value: Test

  PartnerConfigTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: 'PartnerConfig'
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: partner_id
          AttributeType: S
      KeySchema:
        - AttributeName: partner_id
          KeyType: HASH
      SSESpecification:
        SSEEnabled: true
        SSEType: KMS
        KMSMasterKeyId: alias/aws/dynamodb
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Purpose
          Value: PartnerConfiguration
        - Key: Environment
          Value: Test

  # Lambda Execution Role with least privilege
  TokenServiceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: DynamoDBAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                Resource:
                  - !GetAtt TokenCacheTable.Arn
                  - !GetAtt PartnerConfigTable.Arn
        - PolicyName: CognitoAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - cognito-idp:InitiateAuth
                Resource: !GetAtt TestUserPool.Arn
        - PolicyName: CloudWatchLogs
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/TokenService:*'

  # Lambda Function with updated runtime
  TokenServiceFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: 'TokenService'
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt TokenServiceRole.Arn
      Timeout: 30
      Environment:
        Variables:
          TOKEN_CACHE_TABLE: !Ref TokenCacheTable
          PARTNER_CONFIG_TABLE: !Ref PartnerConfigTable
          COGNITO_USER_POOL_ID: !Ref TestUserPool
          COGNITO_CLIENT_ID: !Ref TestUserPoolClient
          COGNITO_DOMAIN: !Sub '${TestUserPool}.auth.${AWS::Region}.amazoncognito.com'
      Code:
        ZipFile: |
          import json
          import boto3
          import os
          import logging
          from datetime import datetime, timedelta
          import base64
          import urllib.request
          import urllib.error
          import urllib.parse
          
          # Configure logging
          logger = logging.getLogger()
          logger.setLevel(logging.INFO)
          
          dynamodb = boto3.resource('dynamodb')
          
          def lambda_handler(event, context):
              try:
                  logger.info(f"Received event: {json.dumps(event)}")
                  
                  # Extract partner_id from query parameters
                  query_params = event.get('queryStringParameters') or {}
                  partner_id = query_params.get('partner_id')
                  
                  if not partner_id:
                      return error_response(400, 'Missing partner_id parameter')
                  
                  cache_key = f"{partner_id}_token"
                  
                  # Check cache first
                  cached_token = get_cached_token(cache_key)
                  if cached_token:
                      logger.info(f"Token found in cache for partner: {partner_id}")
                      return success_response(cached_token, 'cache')
                  
                  # Cache miss - get partner configuration
                  partner_config = get_partner_config(partner_id)
                  if not partner_config:
                      logger.warning(f"Partner configuration not found: {partner_id}")
                      return error_response(404, 'Partner configuration not found')
                  
                  # Get new token using client credentials grant
                  token = get_cognito_token_client_credentials()
                  if not token:
                      return error_response(500, 'Failed to obtain token from Cognito')
                  
                  # Store in cache
                  store_token_cache(cache_key, token, partner_id)
                  logger.info(f"New token obtained and cached for partner: {partner_id}")
                  
                  return success_response(token, 'cognito')
                  
              except Exception as e:
                  logger.error(f"Error processing request: {str(e)}")
                  return error_response(500, 'Internal server error')
          
          def get_cached_token(cache_key):
              table = dynamodb.Table(os.environ['TOKEN_CACHE_TABLE'])
              try:
                  response = table.get_item(Key={'cache_key': cache_key})
                  if 'Item' in response:
                      # Check if token is still valid (not expired)
                      ttl = response['Item'].get('ttl', 0)
                      if ttl > int(datetime.now().timestamp()):
                          return response['Item']['token']
                      else:
                          logger.info(f"Cached token expired for key: {cache_key}")
              except Exception as e:
                  logger.error(f"Error retrieving cached token: {str(e)}")
              return None
          
          def get_partner_config(partner_id):
              table = dynamodb.Table(os.environ['PARTNER_CONFIG_TABLE'])
              try:
                  response = table.get_item(Key={'partner_id': partner_id})
                  return response.get('Item')
              except Exception as e:
                  logger.error(f"Error retrieving partner config: {str(e)}")
                  return None
          
          def get_cognito_token_client_credentials():
              try:
                  # Get partner configuration to retrieve client credentials
                  partner_config = get_partner_config("test-partner-001")  # In production, this would be dynamic
                  if not partner_config:
                      logger.error("Partner configuration not found for token request")
                      return None
                  
                  # Prepare OAuth 2.0 client credentials request
                  client_id = partner_config['cognito_client_id']
                  client_secret = partner_config['cognito_client_secret']
                  domain = partner_config.get('cognito_domain', 'default-domain')
                  
                  token_url = f"https://{domain}.auth.us-east-1.amazoncognito.com/oauth2/token"
                  
                  # Prepare request data
                  data = "grant_type=client_credentials&scope=token-service-api/read token-service-api/write token-service-api/cache"
                  
                  # Create Basic Auth header
                  import base64
                  credentials = f"{client_id}:{client_secret}"
                  encoded_credentials = base64.b64encode(credentials.encode()).decode()
                  
                  # Make the OAuth request
                  req = urllib.request.Request(
                      token_url,
                      data=data.encode(),
                      headers={
                          'Content-Type': 'application/x-www-form-urlencoded',
                          'Authorization': f'Basic {encoded_credentials}'
                      }
                  )
                  
                  with urllib.request.urlopen(req) as response:
                      response_data = json.loads(response.read().decode())
                      access_token = response_data.get('access_token')
                      
                      if access_token:
                          logger.info("Successfully obtained access token from Cognito")
                          return access_token
                      else:
                          logger.error("No access token in Cognito response")
                          return None
                  
              except urllib.error.HTTPError as e:
                  logger.error(f"HTTP error obtaining Cognito token: {e.code} - {e.read().decode()}")
                  return None
              except Exception as e:
                  logger.error(f"Error obtaining Cognito token: {str(e)}")
                  return None
          
          def store_token_cache(cache_key, token, partner_id):
              table = dynamodb.Table(os.environ['TOKEN_CACHE_TABLE'])
              # Set TTL to 50 minutes (token expires in 1 hour)
              ttl = int((datetime.now() + timedelta(minutes=50)).timestamp())
              
              try:
                  table.put_item(
                      Item={
                          'cache_key': cache_key,
                          'token': token,
                          'partner_id': partner_id,
                          'ttl': ttl,
                          'created_at': datetime.now().isoformat()
                      }
                  )
                  logger.info(f"Token cached successfully for partner: {partner_id}")
              except Exception as e:
                  logger.error(f"Error storing token in cache: {str(e)}")
          
          def success_response(token, source):
              return {
                  'statusCode': 200,
                  'headers': {
                      'Content-Type': 'application/json',
                      'Access-Control-Allow-Origin': '*'
                  },
                  'body': json.dumps({
                      'access_token': token,
                      'source': source,
                      'timestamp': datetime.now().isoformat()
                  })
              }
          
          def error_response(status_code, message):
              return {
                  'statusCode': status_code,
                  'headers': {
                      'Content-Type': 'application/json',
                      'Access-Control-Allow-Origin': '*'
                  },
                  'body': json.dumps({
                      'error': message,
                      'timestamp': datetime.now().isoformat()
                  })
              }

  # API Gateway with throttling
  TokenServiceApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: 'TokenService'
      Description: 'M2M Token Caching Service API'
      EndpointConfiguration:
        Types:
          - REGIONAL

  TokenResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref TokenServiceApi
      ParentId: !GetAtt TokenServiceApi.RootResourceId
      PathPart: token

  TokenMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref TokenServiceApi
      ResourceId: !Ref TokenResource
      HttpMethod: GET
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${TokenServiceFunction.Arn}/invocations'
      MethodResponses:
        - StatusCode: 200
          ResponseParameters:
            method.response.header.Access-Control-Allow-Origin: true

  # CORS Options method
  TokenOptionsMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref TokenServiceApi
      ResourceId: !Ref TokenResource
      HttpMethod: OPTIONS
      AuthorizationType: NONE
      Integration:
        Type: MOCK
        IntegrationResponses:
          - StatusCode: 200
            ResponseParameters:
              method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
              method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'"
              method.response.header.Access-Control-Allow-Origin: "'*'"
        RequestTemplates:
          application/json: '{"statusCode": 200}'
      MethodResponses:
        - StatusCode: 200
          ResponseParameters:
            method.response.header.Access-Control-Allow-Headers: true
            method.response.header.Access-Control-Allow-Methods: true
            method.response.header.Access-Control-Allow-Origin: true

  ApiDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn: 
      - TokenMethod
      - TokenOptionsMethod
    Properties:
      RestApiId: !Ref TokenServiceApi
      StageName: 'dev'

  # API Gateway throttling
  ApiUsagePlan:
    Type: AWS::ApiGateway::UsagePlan
    DependsOn: ApiDeployment
    Properties:
      UsagePlanName: 'TokenServiceUsagePlan'
      Description: 'Usage plan for Token Service API'
      ApiStages:
        - ApiId: !Ref TokenServiceApi
          Stage: 'dev'
      Throttle:
        BurstLimit: 100
        RateLimit: 50
      Quota:
        Limit: 10000
        Period: DAY

  # Lambda Permission for API Gateway
  LambdaApiPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref TokenServiceFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${TokenServiceApi}/*/*'

Outputs:
  TokenServiceUrl:
    Description: 'Token Service API Gateway URL'
    Value: !Sub 'https://${TokenServiceApi}.execute-api.${AWS::Region}.amazonaws.com/dev/token'
    Export:
      Name: !Sub '${AWS::StackName}-TokenServiceUrl'

  CognitoUserPoolId:
    Description: 'Created Cognito User Pool ID'
    Value: !Ref TestUserPool
    Export:
      Name: !Sub '${AWS::StackName}-UserPoolId'

  CognitoClientId:
    Description: 'Created Cognito Client ID'
    Value: !Ref TestUserPoolClient
    Export:
      Name: !Sub '${AWS::StackName}-ClientId'

  ResourceServerId:
    Description: 'Created Resource Server ID'
    Value: !Ref TokenServiceResourceServer
    Export:
      Name: !Sub '${AWS::StackName}-ResourceServerId'

  PartnerConfigTableName:
    Description: 'DynamoDB table name for partner configuration'
    Value: !Ref PartnerConfigTable
    Export:
      Name: !Sub '${AWS::StackName}-PartnerConfigTable'

  TokenCacheTableName:
    Description: 'DynamoDB table name for token cache'
    Value: !Ref TokenCacheTable
    Export:
      Name: !Sub '${AWS::StackName}-TokenCacheTable'

⚠️ Remember to clean up test resources after validation to avoid ongoing costs!