Preserving UUID Architecture with Google Sign-In: How We Prevented Duplicate User Records in AWS Cognito

Note: This post will be follow up to Ganesh Prasad’s excellent article on Integrating Google Sign-In with AWS Cognito for Seamless Authentication. I highly recommend reading his post first as it covers the foundational setup that this implementation extends.

In this follow-up article, I’ll share how we implemented the pre-signup logic in our Zoodpay Distributor application to prevent duplicate user records while maintaining our UUID-based architecture.

The Challenge

When implementing social login with Google in AWS Cognito, we faced a critical architectural challenge. In our Distributor application, we had already established a robust database schema using UUIDs as primary keys for all user records. This design was fundamental to our system’s integrity and relationships.

However, by default, AWS Cognito creates new user records with IDs like Google_109999999999999999999 when users sign up through Google. This default behavior presented a serious problem for our application for several reasons:

  1. Database Schema Incompatibility: Our system was designed to use UUIDs as primary keys, not the Google-provided IDs
  2. Duplicate User Records: When a user who already exists in our database with a UUID attempts to sign in with Google, Cognito would create a completely new user record with the Google ID
  3. Data Fragmentation: User data would become fragmented across multiple records, breaking referential integrity
  4. Inconsistent User Experience: Users would have different experiences depending on how they authenticated

This issue was particularly problematic because many of our users already had accounts in our system before we implemented Google sign-in.

Our Solution: The Pre-Signup Lambda Trigger

To solve this problem, we implemented a custom pre-signup Lambda trigger that intercepts the authentication flow when a user signs in with Google. This trigger performs several key functions:

  1. Identifies when the authentication is coming from Google
  2. Checks if a user with the same email already exists in our database
  3. Links the Google identity to the existing user instead of creating a duplicate record

Let’s dive into the implementation details.

Implementation Overview

The pre-signup Lambda function is triggered before a user is created in Cognito. Our solution completely changes the authentication flow for Google sign-in to maintain our UUID-based architecture. Here’s how our implementation works:

export const handler = async (event: PreSignUpTriggerEvent): Promise<PreSignUpTriggerEvent> => {
    if (event.triggerSource !== 'PreSignUp_ExternalProvider') {
        event.response.autoConfirmUser = true
        if (event.request.userAttributes.email) {
            event.response.autoVerifyEmail = true
        }
        return event
    }

    // Handle external provider (Google) sign-up...
}

The first thing we do is check if the trigger source is from an external provider (Google). If not, we simply auto-confirm the user and verify their email if available.

Checking for Existing Users

When a user signs in with Google, we check if their email already exists in our database:

const { email, name } = event.request.userAttributes
const [first_name, last_name] = (name || '').split(' ')

const trx = await db.knex.transaction()

try {
    // Check if email exists in temporary-users table
    const existingEmail = await trx(process.env.TEMPORARY_USER_TABLE_NAME)
        .where('email_address', email)
        .first()

    let destinationUserId

    if (existingEmail) {
        // Check if user exists in emails table
        const existingUser = await trx(process.env.EMAILS_TABLE_NAME)
            .where('user_id', existingEmail.id)
            .first()

        if (!existingUser) {
            throw new Error('Email already registered')
        }

        destinationUserId = existingUser.id
    } else {
        // Create new user logic...
    }
}

This code checks if the email exists in our temporary users table. If it does, we get the user ID to link with the Google identity.

Creating a New User (Only When Needed)

If no existing user is found, only then do we create a new user record. Critically, we generate our own UUID rather than using the Google-provided ID, maintaining our database schema integrity:

// Create new user in DB
const userId = uuidv4()
const pin = SecurePinCrypto.generatePin()
const password = await generatePassword()

const userData = {
    first_name,
    last_name,
    email_address: email,
    id: userId,
    status: true,
    user_type: 'MDST/DST',
    encrypted_pin: await SecurePinCrypto.encryptPin(pin, userId),
}

const [distributor] = await trx(process.env.TEMPORARY_USER_TABLE_NAME)
    .insert(userData)
    .returning(['id', 'user_code', 'email_address'])

await trx(process.env.EMAILS_TABLE_NAME).insert({
    id: uuidv4(),
    user_id: distributor.id,
    user_code: distributor.user_code,
    user_type: 'TMP',
    email_address: email,
})

// Create Cognito user
await adminCreateUser(
    event.userPoolId,
    userId,
    [
        { Name: "name", Value: name },
        { Name: "preferred_username", Value: distributor.user_code },
        { Name: "custom:user_type", Value: 'TMP' },
        { Name: "custom:user_verified", Value: 'false' },
        { Name: "email", Value: email }
    ],
    password,
    [{ Name: "email_verified", Value: "true" }]
)

The Magic: Linking Google Identity to Existing User

The most critical part of our implementation is linking the Google identity to either the existing user or the newly created user. This is where we override Cognito’s default behavior of creating users with Google IDs and instead maintain our UUID-based architecture:

try {
    const SourceUserId = (event.userName).split('_')[1]
    await cognito.adminLinkProviderForUser({
        UserPoolId: event.userPoolId,
        DestinationUser: {
            ProviderName: 'Cognito',
            ProviderAttributeName: 'Cognito_Subject',
            ProviderAttributeValue: destinationUserId,
        },
        SourceUser: {
            ProviderName: 'Google',
            ProviderAttributeName: 'Cognito_Subject',
            ProviderAttributeValue: SourceUserId,
        }
    }).promise()
} catch (linkError: any) {
    // Handle already linked users - allow login to continue
    if (linkError.code === 'InvalidParameterException' &&
        linkError.message.includes('already linked')) {
        console.log('User already linked, allowing login...')
    } else {
        throw linkError
    }
}

This code extracts the Google user ID from the event’s userName (which has the format Google_[ID]), and links it to our Cognito user with our UUID-based ID. The adminLinkProviderForUser API call is the key that prevents the creation of duplicate user records.

How the New Logic Works

  1. Intercept Pre-Signup Event: We catch the authentication event before Cognito creates a new user
  2. Extract Email and Identity: We get the user’s email from Google’s authentication data
  3. Database Lookup: We check if this email already exists in our system
  4. For Existing Users:
    • We retrieve the user’s UUID from our database
    • We link the Google identity to this existing UUID in Cognito
    • No new user record is created
  5. For New Users:
    • We generate a new UUID (not using Google’s ID)
    • We create a new user record in our database with this UUID
    • We create a corresponding Cognito user with the same UUID
    • We link the Google identity to this new UUID
  6. Complete the Authentication: The user is authenticated with their original or new UUID, not the Google ID

This approach ensures that whether a user signs in with email/password or Google, they always use the same UUID-based user record in our system.

Error Handling and Edge Cases

We’ve also implemented robust error handling:

  1. We use database transactions to ensure data consistency
  2. We handle the case where a user is already linked (allowing login to continue)
  3. We properly roll back transactions if any part of the process fails

Benefits of This Approach

This implementation provides several critical benefits:

  1. Maintained Database Architecture: We preserved our UUID-based primary key system without compromise
  2. Unified User Experience: Users can sign in with either their email/password or Google account and access the same user record
  3. Data Integrity: Prevents duplicate user records and maintains referential integrity across our database
  4. Seamless Authentication: Users who previously registered with email/password can start using Google sign-in without creating a new account
  5. Future-Proof Design: This approach makes it easier to add other social providers (Facebook, Apple, etc.) in the future

Conclusion

By implementing this pre-signup Lambda trigger, we’ve created a seamless authentication experience while maintaining our UUID-based architecture. AWS Cognito’s flexibility with Lambda triggers allowed us to customize the authentication flow to meet our specific requirements without compromising our database design.

This is just one approach to solving this challenge. Other solutions might include adapting your database schema, implementing a custom authentication flow, or creating a mapping layer between Cognito IDs and your application’s IDs.

How would you have implemented this solution in your system? Would you have taken a different approach if your architecture had different constraints? I’d love to hear your thoughts and alternative implementation strategies in the comments!

Why We Chose This Particular Approach

There were several compelling reasons why we selected this specific implementation over alternatives:

  1. Reversed Control Flow: We completely reversed the default control flow. Instead of letting Cognito create a user with Google data first, we intercept the process before Cognito creates any record. We create our UUID-based user first and then link the Google identity to it.

  2. Minimal Impact on Existing Systems: Our approach required no changes to existing database queries or API endpoints

  3. Efficient Implementation: This solution required changes only to the pre-signup Lambda trigger, not widespread modifications across our codebase

4 Likes