Development/AWS

Building Secure Microservice Authentication with AWS CDK: A Service-to-Service Authentication Pattern Using Cognito

kozylife 2025. 7. 15. 08:33

As microservice architectures become mainstream, secure inter-service communication is no longer optional—it's essential. This becomes especially critical when one service needs to create and manage users in another service, requiring an authentication system that satisfies both security and scalability requirements.

Today, I'll introduce a practical pattern for implementing service-to-service (S2S) authentication using Amazon Cognito User Pools.

Terminology Clarification: Understanding OAuth 2.0 Flows

First, let's clarify the authentication method used in this pattern. This implementation uses the OAuth 2.0 Resource Owner Password Credentials Grant flow.

Important Note: While you might see references to 'Client Credentials Flow' in the title, here's the accurate breakdown:

  • Actual Implementation: Creates a 'machine role' user (app1-api-client) in the Cognito User Pool and uses this user's credentials (username/password) to obtain JWT tokens
  • Standard Client Credentials Flow: Pure M2M communication using only Client ID & Client Secret without any user concept

This pattern provides a practical approach to implementing inter-service authentication while leveraging Cognito User Pool's group-based permission management.

Why is Service-to-Service Authentication Complex?

Limitations of Traditional Approaches

1. API Key Method Issues

# Simple API Key authentication
curl -X POST https://service-b.com/create-user \
  -H "X-API-Key: hardcoded-secret-key" \
  -d '{"username": "newuser", "password": "plaintext"}'
  • Security Vulnerabilities: If API Key is exposed, all permissions are compromised
  • Difficult Permission Management: Fine-grained access control is impossible
  • Poor Scalability: Key management complexity increases exponentially with services

2. Shared Database Approach

# Service A directly accessing Service B's DB
def create_user_in_service_b(username, password):
    # Direct connection to Service B's DB - Anti-pattern!
    service_b_db.users.insert({
        'username': username,
        'password': hash_password(password)
    })
  • Increased Coupling: Creates strong dependencies between services
  • Data Consistency: Complex transaction management
  • Blurred Security Boundaries: Difficult per-service access control

The Rise of Cognito-Based S2S Authentication Pattern

Core Concept

"Each service maintains its own authentication domain, while inter-service communication uses standardized JWT tokens"

App1 (User Management)  →  App2 (Business Logic)
     ↓                      ↓
1. Cognito Authentication  1. JWT Validation
2. Encrypted Request       2. Permission Check
3. User Creation Request   3. Secure User Creation

Real Implementation: User Management System Example

System Architecture

┌─────────────────┐    ┌─────────────────┐
│   App1 Stack    │    │   App2 Stack    │
│                 │    │                 │
│ ┌─────────────┐ │    │ ┌─────────────┐ │
│ │   Lambda    │ │────│→│ API Gateway │ │
│ │ (User Mgmt) │ │    │ │   + JWT     │ │
│ └─────────────┘ │    │ │ Authorizer  │ │
│ ┌─────────────┐ │    │ └─────────────┘ │
│ │ API Gateway │ │    │ ┌─────────────┐ │
│ └─────────────┘ │    │ │ Cognito     │ │
│                 │    │ │ User Pool   │ │
└─────────────────┘    │ │ - api-client│ │
                       │ │ - service-  │ │
                       │ │   user      │ │
                       │ └─────────────┘ │
                       │ ┌─────────────┐ │
                       │ │  Lambda     │ │
                       │ │ Functions   │ │
                       │ └─────────────┘ │
                       └─────────────────┘

Core Component Design

1. App2 Cognito User Pool Configuration

// CDK code example
const userPool = new cognito.UserPool(this, 'App2UserPool', {
  userPoolName: 'app2-service-users',
  passwordPolicy: {
    minLength: 12,
    requireLowercase: true,
    requireUppercase: true,
    requireDigits: true,
    requireSymbols: true,
  },
  signInAliases: {
    username: true,
  },
});

// API Client Group (user creation permissions)
const apiClientGroup = new cognito.CfnUserPoolGroup(this, 'ApiClientGroup', {
  userPoolId: userPool.userPoolId,
  groupName: 'api-client',
  description: 'Group for API clients that can create service users',
});

// Service User Group (general business logic access)
const serviceUserGroup = new cognito.CfnUserPoolGroup(this, 'ServiceUserGroup', {
  userPoolId: userPool.userPoolId,
  groupName: 'service-user', 
  description: 'Group for service users that can access business logic',
});

2. Path-based JWT Authorizer

// JWT token validation and permission check
export const jwtAuthorizer = async (event: APIGatewayTokenAuthorizerEvent) => {
  try {
    const token = event.authorizationToken.replace('Bearer ', '');
    const decoded = jwt.verify(token, jwk) as any;
    
    // Group-based path access control
    const userGroups = decoded['cognito:groups'] || [];
    const methodArn = event.methodArn;
    
    if (methodArn.includes('/admin/create-user')) {
      // User creation only allowed for api-client group
      if (!userGroups.includes('api-client')) {
        throw new Error('Insufficient permissions');
      }
    } else if (methodArn.includes('/sample')) {
      // Business logic only allowed for service-user group
      if (!userGroups.includes('service-user')) {
        throw new Error('Insufficient permissions');
      }
    }
    
    return generatePolicy('user', 'Allow', event.methodArn, decoded);
  } catch (error) {
    throw new Error('Unauthorized');
  }
};

Security Enhancement: Encrypted Password Transmission

Encryption Method Comparison

1. AES-256-CBC (Basic Implementation)

// For learning purposes - many considerations for direct implementation
- Vulnerable to Padding Oracle attacks
- Complex IV management
- Requires separate data integrity verification

2. AES-256-GCM (Recommended)

// Improved method - includes integrity verification
- Detects data tampering with authentication tags
- More secure encryption mode
- Better performance than CBC

3. AWS KMS (Production Recommended)

// Best practice - fully managed service
- Hardware security module based
- Automatic key rotation and management
- Comprehensive access control and auditing

AES-256-GCM Implementation (Improved Method)

Password Encryption in App1

import * as crypto from 'crypto';

function encryptPassword(password: string, encryptionKey: string): string {
  const algorithm = 'aes-256-cbc';
  const key = Buffer.from(encryptionKey, 'hex');
  const iv = crypto.randomBytes(16); // Explicitly generate IV
  
  const cipher = crypto.createCipheriv(algorithm, key, iv); // Encrypt using IV
  
  let encrypted = cipher.update(password, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  
  return iv.toString('hex') + ':' + encrypted; // Return IV and ciphertext together
}

// More secure AES-256-GCM method (recommended)
function encryptPasswordGCM(password: string, encryptionKey: string): string {
  const algorithm = 'aes-256-gcm';
  const key = Buffer.from(encryptionKey, 'hex');
  const iv = crypto.randomBytes(16);
  
  const cipher = crypto.createCipheriv(algorithm, key, iv);
  
  let encrypted = cipher.update(password, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  
  const authTag = cipher.getAuthTag(); // Authentication tag for data integrity
  
  return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}

// App1's user creation function
export const createServiceUser = async (event: APIGatewayProxyEvent) => {
  const { username, password, human_user_id } = JSON.parse(event.body || '{}');
  
  // 1. Authenticate with App2
  const authResponse = await authenticate();
  const accessToken = authResponse.AccessToken;
  
  // 2. Encrypt password
  const encryptedPassword = encryptPassword(password, process.env.ENCRYPTION_KEY!);
  
  // 3. Request user creation from App2
  const response = await fetch(`${process.env.APP2_API_URL}/admin/create-user`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      username,
      encrypted_password: encryptedPassword,
      human_user_id,
    }),
  });
  
  return {
    statusCode: 200,
    body: JSON.stringify({ message: 'Service user created successfully' }),
  };
};

Password Decryption in App2

function decryptPassword(encryptedPassword: string, encryptionKey: string): string {
  const algorithm = 'aes-256-cbc';
  const key = Buffer.from(encryptionKey, 'hex');
  
  const parts = encryptedPassword.split(':');
  const iv = Buffer.from(parts[0], 'hex');
  const encrypted = parts[1];
  
  const decipher = crypto.createDecipheriv(algorithm, key, iv); // Decrypt using IV
  
  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  
  return decrypted;
}

// AES-256-GCM decryption (recommended)
function decryptPasswordGCM(encryptedPassword: string, encryptionKey: string): string {
  const algorithm = 'aes-256-gcm';
  const key = Buffer.from(encryptionKey, 'hex');
  
  const parts = encryptedPassword.split(':');
  const iv = Buffer.from(parts[0], 'hex');
  const authTag = Buffer.from(parts[1], 'hex');
  const encrypted = parts[2];
  
  const decipher = crypto.createDecipheriv(algorithm, key, iv);
  decipher.setAuthTag(authTag); // Integrity verification
  
  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  
  return decrypted;
}

// App2's user creation function
export const createUser = async (event: APIGatewayProxyEvent) => {
  const { username, encrypted_password, human_user_id } = JSON.parse(event.body || '{}');
  
  // 1. Decrypt password
  const password = decryptPassword(encrypted_password, process.env.ENCRYPTION_KEY!);
  
  // 2. Create user in Cognito
  await cognitoClient.send(new AdminCreateUserCommand({
    UserPoolId: process.env.USER_POOL_ID!,
    Username: username,
    TemporaryPassword: password,
    MessageAction: 'SUPPRESS',
    UserAttributes: [
      { Name: 'email_verified', Value: 'true' },
      { Name: 'custom:human_user_id', Value: human_user_id },
    ],
  }));
  
  // 3. Set permanent password and add to group
  await cognitoClient.send(new AdminSetUserPasswordCommand({
    UserPoolId: process.env.USER_POOL_ID!,
    Username: username,
    Password: password,
    Permanent: true,
  }));
  
  await cognitoClient.send(new AdminAddUserToGroupCommand({
    UserPoolId: process.env.USER_POOL_ID!,
    Username: username,
    GroupName: 'service-user',
  }));
  
  return {
    statusCode: 200,
    body: JSON.stringify({ message: 'User created successfully' }),
  };
};

Real-World Use Cases

1. E-commerce Platform

Order Management Service → Payment Service
- Automatically create merchant accounts in payment service upon order completion
- Each merchant can only access their own payment data
- Enhanced security through encrypted API key delivery

2. Multi-tenant SaaS

Tenant Management Service → Various Business Services
- Create tenant-specific users across all services when new customers sign up
- Service-specific isolated data access
- Centralized user lifecycle management

3. Financial System

Account Opening Service → Transaction Processing Service
- Create customer accounts in transaction system after KYC completion
- Encrypted personal information transfer for regulatory compliance
- Traceability for audit logs

Step-by-Step Implementation Guide

Step 1: Environment Setup and Deployment

# Clone project and setup
git clone https://github.com/jaeneungsim/cdk-cognito-s2s-auth-pattern.git
cd cdk-cognito-s2s-auth-pattern
npm install

# CDK bootstrap (first time only)
cdk bootstrap

# Deploy all stacks
cdk deploy --all

Step 2: Manual API Client Creation

# Create API client in App2's Cognito User Pool
aws cognito-idp admin-create-user \
  --user-pool-id <APP2_USER_POOL_ID> \
  --username "app1-api-client" \
  --temporary-password "TempPassword123!" \
  --message-action SUPPRESS

# Add to api-client group
aws cognito-idp admin-add-user-to-group \
  --user-pool-id <APP2_USER_POOL_ID> \
  --username "app1-api-client" \
  --group-name "api-client"

# Set permanent password
aws cognito-idp admin-set-user-password \
  --user-pool-id <APP2_USER_POOL_ID> \
  --username "app1-api-client" \
  --password "ProtoPassword123!" \
  --permanent

Step 3: Test Inter-Service Communication

# Create service user through App1
curl -X POST https://your-app1-api.amazonaws.com/create-service-user \
  -H "Content-Type: application/json" \
  -d '{
    "username": "test-service-user",
    "password": "ServicePassword123!",
    "human_user_id": "admin@company.com"
  }'

# Authenticate as service user and call App2 business logic
curl -X POST https://your-app2-api.amazonaws.com/sample \
  -H "Authorization: Bearer <SERVICE_USER_JWT_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "request_id": "12345",
      "payload": "sample data"
    }
  }'

Pattern Pros and Cons Analysis

Advantages

1. Strong Security

  • JWT-based authentication with token expiration limits even if compromised
  • Fine-grained group-based permission control
  • Encrypted password transmission prevents man-in-the-middle attacks

2. Scalability

  • Minimal configuration needed when adding new services
  • Leverage AWS Cognito's unlimited scalability
  • Independent authentication domains per service

3. Operational Efficiency

  • Centralized user lifecycle management
  • Minimal operational burden with AWS managed services
  • Unified monitoring through CloudWatch

Considerations

1. Initial Setup Complexity

  • Multi-AWS service integration configuration
  • Complex IAM policies and Cognito settings
  • Requires team understanding of AWS services

2. Debugging Complexity

  • Difficult to trace requests across multiple services
  • Complex JWT token-related issue diagnosis
  • Requires comprehensive log analysis through CloudTrail

3. Cost Considerations

  • Cognito usage-based billing
  • API Gateway call volume costs
  • Lambda execution time and memory usage

Production Hardening

1. Security Enhancement

Important: The direct encryption methods explained earlier are for learning purposes. AWS KMS must be used in production.

// AWS KMS encryption (Production recommended approach)
import { KMSClient, EncryptCommand, DecryptCommand } from '@aws-sdk/client-kms';

const kmsClient = new KMSClient({ region: process.env.AWS_REGION });

async function encryptPasswordWithKMS(password: string): Promise<string> {
  const command = new EncryptCommand({
    KeyId: 'alias/cross-service-encryption-key',
    Plaintext: Buffer.from(password, 'utf-8'),
  });
  
  const result = await kmsClient.send(command);
  return Buffer.from(result.CiphertextBlob!).toString('base64');
}

async function decryptPasswordWithKMS(encryptedPassword: string): Promise<string> {
  const command = new DecryptCommand({
    CiphertextBlob: Buffer.from(encryptedPassword, 'base64'),
  });
  
  const result = await kmsClient.send(command);
  return Buffer.from(result.Plaintext!).toString('utf-8');
}

KMS Usage Benefits:

  • Automated key management (key rotation, access control)
  • Automatic audit log generation (CloudTrail)
  • Hardware Security Module (HSM) based encryption
  • Key usage monitoring

2. Monitoring and Alerting

// CloudWatch metrics and alarm configuration
const authFailureAlarm = new cloudwatch.Alarm(this, 'AuthFailureAlarm', {
  metric: new cloudwatch.Metric({
    namespace: 'AWS/Cognito',
    metricName: 'SignInThrottles',
    dimensionsMap: {
      UserPool: userPool.userPoolId,
    },
  }),
  threshold: 10,
  evaluationPeriods: 2,
  treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});

authFailureAlarm.addAlarmAction(new cloudwatchActions.SnsAction(alertTopic));

3. Secret Management

// Using AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const secretsClient = new SecretsManagerClient({ region: process.env.AWS_REGION });

async function getApiClientCredentials(): Promise<{username: string, password: string}> {
  const command = new GetSecretValueCommand({
    SecretId: 'app1-api-client-credentials',
  });
  
  const result = await secretsClient.send(command);
  return JSON.parse(result.SecretString!);
}

Application Scenario Guide

When to Use This Pattern?

Suitable Cases:

  • Inter-service user management needed in microservice architectures
  • High security level required (finance, healthcare domains)
  • Multi-tenant environments where tenant isolation is critical
  • Audit logs and access control needed for regulatory compliance

Unsuitable Cases:

  • Single service applications
  • Real-time performance critical systems (games, chat applications)
  • Prototypes or personal projects
  • Systems with very high user creation frequency (consider async processing)

Migration Strategy

Gradual Application from Existing Systems:

// Phase 1: Run alongside existing API
app.post('/create-user', async (req, res) => {
  if (req.headers['x-use-cognito'] === 'true') {
    // New Cognito-based logic
    return await createUserWithCognito(req, res);
  } else {
    // Maintain existing logic
    return await createUserLegacy(req, res);
  }
});

// Phase 2: Gradual transition through feature flags
const useNewAuth = await featureFlagClient.getBoolVariation(
  'use-cognito-auth', 
  defaultValue: false
);

Conclusion

Service-to-service authentication is one of the core elements of microservice architecture. This pattern using AWS Cognito User Pool's group-based permission management provides a practical solution that satisfies both security and scalability.

Key benefits include:

  • Standardized Approach: Industry-standard JWT-based authentication
  • AWS Ecosystem Leverage: Minimal operational burden with managed services
  • Fine-grained Access Control: Flexible group-based access control
  • Strong Security: Encrypted communication and temporary credentials

While there is complexity in initial setup and debugging challenges, if you need to build a stable and scalable authentication system in a production environment, this pattern is definitely worth considering.

Important Security Considerations:

  • Accurate understanding and use of OAuth 2.0 terminology
  • Must use AWS KMS in production
  • Follow latest security best practices for encryption implementation

If your next project requires secure inter-service communication, consider reviewing this Cognito-based S2S authentication pattern.

References: