Implementing Federated Identity Providers in AWS Cognito with Email-Based Account Linking

Implementing Federated Identity Providers in AWS Cognito with Email-Based Account Linking

When implementing authentication in web applications, it’s common to support multiple sign-in methods through federated identity providers (IdPs) like Apple, Google, or Facebook. However, managing multiple identities for the same user can be challenging. This guide demonstrates how to implement a solution that links accounts based on email addresses using AWS Cognito.

The Challenge

By default, when users sign in through different identity providers or username/password, Cognito creates separate user entries for each method - even when they use the same email address. This leads to:

  1. Fragmented user experience
  2. Complicated user management
  3. Potential confusion when users try different sign-in methods
  4. Difficulty maintaining consistent user data across providers

A Solution

This documents a solution that:

  1. Creates a single Cognito user for each unique email
  2. Links all sign-in methods to this base user
  3. Works with any federated identity provider
  4. Provides a path for users to set up password-based authentication

We’ll use Sign in with Apple as our primary example, but the approach works with any IdP.

Prerequisites

  • AWS Account with Cognito User Pool
  • At least one configured identity provider (Apple, Google, Facebook, etc.)
  • Basic understanding of AWS Lambda and IAM

Implementation Steps

1. Set Up the Lambda Function

Create a PreSignUp Lambda trigger for your Cognito User Pool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
const {
  CognitoIdentityProviderClient,
  ListUsersCommand,
  AdminCreateUserCommand,
  AdminLinkProviderForUserCommand,
  AdminSetUserPasswordCommand,
} = require('@aws-sdk/client-cognito-identity-provider');
const crypto = require('crypto');

const client = new CognitoIdentityProviderClient();

// Map provider names to their correct Cognito format
const PROVIDER_MAPPING = {
  signinwithapple: 'SignInWithApple',
  google: 'Google',
  facebook: 'Facebook',
  // Add other providers as needed
};

const getProviderName = (rawProviderName) => {
  const normalized = rawProviderName.toLowerCase();
  return (
    PROVIDER_MAPPING[normalized] ||
    rawProviderName.charAt(0).toUpperCase() + rawProviderName.slice(1).toLowerCase()
  );
};

exports.handler = async (event) => {
  console.log('PreSignUp event:', JSON.stringify(event, null, 2));

  try {
    const {
      triggerSource,
      userPoolId,
      userName,
      request: { userAttributes },
    } = event;

    const email = userAttributes.email;

    if (triggerSource === 'PreSignUp_ExternalProvider') {
      console.log('Processing external provider signup');

      // Find existing user
      const listCommand = new ListUsersCommand({
        UserPoolId: userPoolId,
        Filter: `email = "${email}"`,
      });

      const { Users } = await client.send(listCommand);

      // Parse provider info
      const [rawProviderName, providerUserId] = userName.split('_');
      const providerName = getProviderName(rawProviderName);

      if (!Users || Users.length === 0) {
        // Create new Cognito user
        const createCommand = new AdminCreateUserCommand({
          UserPoolId: userPoolId,
          Username: email,
          MessageAction: 'SUPPRESS',
          UserAttributes: [
            { Name: 'email', Value: email },
            { Name: 'email_verified', Value: 'true' },
          ],
        });

        const { User } = await client.send(createCommand);

        // Set random password
        const setPasswordCommand = new AdminSetUserPasswordCommand({
          UserPoolId: userPoolId,
          Username: User.Username,
          Password: crypto.randomBytes(32).toString('hex') + '!A1',
          Permanent: true,
        });

        await client.send(setPasswordCommand);

        // Link provider
        const linkCommand = new AdminLinkProviderForUserCommand({
          UserPoolId: userPoolId,
          DestinationUser: {
            ProviderName: 'Cognito',
            ProviderAttributeValue: User.Username,
          },
          SourceUser: {
            ProviderName: providerName,
            ProviderAttributeName: 'Cognito_Subject',
            ProviderAttributeValue: providerUserId,
          },
        });

        await client.send(linkCommand);
      } else {
        // Link to existing user
        const existingUser = Users[0];

        const linkCommand = new AdminLinkProviderForUserCommand({
          UserPoolId: userPoolId,
          DestinationUser: {
            ProviderName: 'Cognito',
            ProviderAttributeValue: existingUser.Username,
          },
          SourceUser: {
            ProviderName: providerName,
            ProviderAttributeName: 'Cognito_Subject',
            ProviderAttributeValue: providerUserId,
          },
        });

        await client.send(linkCommand);
      }

      event.response.autoConfirmUser = true;
      event.response.autoVerifyEmail = true;
    }

    return event;
  } catch (error) {
    console.error('Error in PreSignUp trigger:', error);
    throw error;
  }
};

2. Configure IAM Permissions

The Lambda function needs these permissions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "cognito-idp:AdminCreateUser",
        "cognito-idp:AdminSetUserPassword",
        "cognito-idp:AdminUpdateUserAttributes",
        "cognito-idp:AdminLinkProviderForUser",
        "cognito-idp:ListUsers"
      ],
      "Resource": "arn:aws:cognito-idp:REGION:ACCOUNT:userpool/POOL_ID"
    }
  ]
}

3. Configure Your Identity Providers

Each IdP requires specific configuration in Cognito:

  1. Sign in with Apple

    • Provider name must be exactly “SignInWithApple”
    • Requires Apple Developer Account setup
  2. Google

    • Provider name: “Google”
    • Requires OAuth 2.0 client configuration
  3. Facebook

    • Provider name: “Facebook”
    • Requires Facebook App setup

4. Handle Password-Based Sign-In

After users sign in with a federated provider, they might want to also use email/password authentication. There are two main approaches:

  1. Use the “Forgot Password” Flow
1
2
3
4
// In your frontend code
await Auth.forgotPassword(email);
// User receives code via email
await Auth.forgotPasswordSubmit(email, code, newPassword);
  1. Custom Password Setup Flow
1
2
3
4
5
6
7
8
9
10
11
// Example of a custom setup endpoint
const setupPassword = async (user, newPassword) => {
  const params = {
    UserPoolId: userPoolId,
    Username: user.username,
    Password: newPassword,
    Permanent: true,
  };

  await cognitoClient.send(new AdminSetUserPasswordCommand(params));
};

Common Issues and Solutions

1. Provider Name Formatting

Different providers require specific name formats. Use a mapping object like our PROVIDER_MAPPING to ensure consistency.

2. Account Linking Errors

  • “Already linked” errors are common and should be handled gracefully
  • Some providers might not support linking to existing accounts
  • Always verify email ownership before linking

3. Race Conditions

When multiple sign-up attempts occur simultaneously:

1
2
3
4
5
6
7
8
// Use atomic operations when possible
const result = await client.send(
  new ListUsersCommand({
    UserPoolId: userPoolId,
    Filter: `email = "${email}"`,
    Limit: 1,
  }),
);

Best Practices

  1. Error Handling

    • Log all errors with context
    • Handle provider-specific edge cases
    • Provide clear user feedback
  2. Security

    • Verify email ownership
    • Use strong random passwords
    • Implement rate limiting
    • Monitor for suspicious activity
  3. User Experience

    • Clear messaging about available sign-in options
    • Smooth account linking process
    • Easy password setup flow
  4. Monitoring

    • CloudWatch metrics for sign-ins
    • Alert on high error rates
    • Track provider usage

Future Improvements

Scenario 1: User Wants to Change Their Account Email

This is not trivial since the email is used as the linking key. We need to:

  • Update the email in Cognito
  • Handle any linked social providers
  • Ensure email verification

Scenario 2: User Changes Social IdP Email

This is more complex because it can happen outside our system. We need to handle:

Detection of email change Potential conflicts with existing accounts Maintaining account links

This is also tricky because the email is used as the linking key. We need to:

  • Verify the user wants to unlink the provider
  • Remove the provider from the user

Best Practices for Email Changes

Verification

Always require verification of new email addresses Keep old email active until new one is verified Consider temporary dual-delivery of notifications

Security

Notify both old and new email addresses of the change Implement cooling-off period for security-critical operations Log all email change operations

User Experience

Clear communication about the process Handle failed verifications gracefully Provide support path for issues

Data Integrity

Update all relevant systems Maintain audit trail Consider rollback procedures

An Alternative Approach

Instead of using email as the key identifier, consider:

  1. Using a UUID as the primary identifier
  2. Treating email as an attribute rather than an identifier
  3. Supporting multiple verified emails per account
  4. Allowing any social provider regardless of email

This would look more like:

1
2
3
4
5
6
7
8
9
10
User Account (UUID: abc-123)
├── Primary Email: [email protected]
├── Linked Providers:
│   ├── Google ([email protected])
│   ├── Microsoft ([email protected])
│   └── Apple ([email protected])
└── Verified Emails:
    ├── [email protected] (primary)
    ├── [email protected]
    └── [email protected]

Benefits of ID-Based Approach

  1. Flexibility
    • Users can link any provider they trust
    • Support for multiple professional/personal identities
    • Easy addition of new providers
  2. Future-Proofing
    • Better handles evolving identity scenarios
    • Supports enterprise SSO solutions
    • More resilient to provider changes
  3. User Experience
    • Users can choose their preferred login method
    • Clear separation of authentication and communication
    • Support for multiple verified emails

Implementation Considerations

If you’re building a new system, consider starting with this more flexible approach. If you’re maintaining an existing system, carefully weigh the benefits against migration complexity.

Key points to consider:

  • Strong audit trail for security
  • Clear UI for managing multiple identities
  • Robust email verification process
  • Provider-specific user ID tracking

Conclusion

While our email-based implementation provides a solid foundation, consider your specific use cases carefully. For new projects, an ID-based approach might offer more flexibility and better reflect real-world identity scenarios. For existing systems, weigh the benefits of migration against your users’ needs and your resources.

Remember to:

  • Think beyond simple email-to-account mapping
  • Consider enterprise and professional use cases
  • Plan for evolving identity requirements
  • Focus on user experience and security

Resources

© Mark Norgren. Some rights reserved.

Build Date: 2025-06-06

3f535e3