Skip to content

AWS Cognito User Pools: Sign in with Email, Google, or SAML and link to a single user

Sign in page
Pretty picture of the Login UI we build in the React Cognito User Pools article.

Cognito User Pools is an AWS service that provides applications with user identity and auth. This series of articles cover a full stack solution that enables users to sign in with their Email + Password, Google Sign In, or SSO (SAML), and link all methods to the same user within the app:

  1. Create Resources with CDK
  2. Sign in with Email, Google, or SAML and link to a single user 👈 You are here
  3. Add Auth to a React App

This article focuses on the logic behind the Cognito Triggers (Lambda Functions) that fire when users register or sign in. This solution allows users to sign in with multiple methods such as Email + Password, Google, and SAML, and have them all link to the same account within the app.

Cognito Triggers

The core of this solution relies on two Cognito Triggers. The high-level flow looks like this:

  1. The Pre sign-up Trigger fires when a user signs up using Google, SAML, or Email + Password. It’s also triggered when an existing user signs in using a new external identity for the first time (e.g. if a user signs up using SAML and then later signs in using Google SSO with the same email).
    1. If they’re signing up with Email + Password, continue.
    2. If they’re signing up via Google or SAML and they haven’t already signed up using a different method with the same email: a native Cognito User is created for that email.
    3. The External Identity is linked to the native Cognito User for that email.
  2. The Pre token generation Trigger fires on every sign in.
    1. If this is their first time signing in, a User record is created in DynamoDB.
    2. If the user has External Identities linked to their account, the account is marked as verified.

We’ll first look at the code for each trigger and then discuss the details.

Pre Sign-up Lambda Handler

packages/cognito/cognito-pre-signup.ts
import { randomBytes } from 'crypto'
import {
AdminCreateUserCommand,
AdminLinkProviderForUserCommand,
AdminSetUserPasswordCommand,
CognitoIdentityProviderClient,
ListUsersCommand,
MessageActionType,
} from '@aws-sdk/client-cognito-identity-provider'
import type { PreSignUpTriggerEvent } from 'aws-lambda'
import UnsupportedIdentityProviderNameException from './exceptions/UnsupportedIdentityProviderNameException'
import getFullName from './getFullName'
const providerNamesLowerCaseLookup = {
'google': 'Google',
// 'facebook': 'Facebook',
}
// Env vars are passed in via CDK defined in the previous article
const { AUTO_VERIFY_USERS, GOOGLE_SAML_IDENTITY_PROVIDER_NAME } = process.env
if (GOOGLE_SAML_IDENTITY_PROVIDER_NAME) {
providerNamesLowerCaseLookup[GOOGLE_SAML_IDENTITY_PROVIDER_NAME.toLowerCase()] = GOOGLE_SAML_IDENTITY_PROVIDER_NAME
}
const cognitoIdpClient = new CognitoIdentityProviderClient({ region: 'us-west-2' })
export async function handler (event: PreSignUpTriggerEvent) {
switch(event.triggerSource) {
case 'PreSignUp_ExternalProvider':
await handleExternalProvider({ event })
break
case 'PreSignUp_SignUp':
case 'PreSignUp_AdminCreateUser':
if (AUTO_VERIFY_USERS) {
event.response.autoConfirmUser = true
event.response.autoVerifyEmail = true
}
}
return event
}
async function handleExternalProvider({ event }: { event: PreSignUpTriggerEvent }) {
const {
userName,
userPoolId,
request: {
userAttributes,
},
} = event
const { email, name, given_name: givenName, family_name: familyName } = userAttributes
const [providerName, providerUserId] = userName.split('_')
const providerNameWithCorrectCapitalization = providerNamesLowerCaseLookup[providerName.toLowerCase()]
if (!providerNameWithCorrectCapitalization) {
throw new UnsupportedIdentityProviderNameException({ providerName, validIdentityProviderNamesMap: providerNamesLowerCaseLookup })
}
const cognitoUsersWithEmail = await listCognitoUsersWithEmail({ userPoolId, email })
if (cognitoUsersWithEmail.Users?.length) {
const cognitoUsername = cognitoUsersWithEmail.Users[0].Username || 'username-not-found'
await linkCognitoUserAccounts({ cognitoUsername, userPoolId, providerName: providerNameWithCorrectCapitalization, providerUserId })
} else {
const newNativeCognitoUser = await createCognitoUser({ userPoolId, email, givenName, familyName, name })
const newNativeCognitoUserUsername = newNativeCognitoUser.User?.Username || 'username-not-found'
await linkCognitoUserAccounts({ cognitoUsername: newNativeCognitoUserUsername, userPoolId, providerName: providerNameWithCorrectCapitalization, providerUserId })
}
return event
}
function listCognitoUsersWithEmail ({ userPoolId, email }) {
return cognitoIdpClient.send(new ListUsersCommand({
UserPoolId: userPoolId,
Filter: `email = '${email}'`,
}))
}
function linkCognitoUserAccounts ({ cognitoUsername, userPoolId, providerName, providerUserId }) {
return cognitoIdpClient.send(new AdminLinkProviderForUserCommand({
SourceUser: {
ProviderName: providerName,
ProviderAttributeName: 'Cognito_Subject',
ProviderAttributeValue: providerUserId,
},
DestinationUser: {
ProviderName: 'Cognito',
ProviderAttributeValue: cognitoUsername,
},
UserPoolId: userPoolId,
}))
}
async function createCognitoUser ({ userPoolId, email, givenName, familyName, name }) {
const fullName = getFullName({ name, givenName, familyName })
const createdCognitoUser = await cognitoIdpClient.send(new AdminCreateUserCommand({
UserPoolId: userPoolId,
// Don't send an email with the temporary password
MessageAction: MessageActionType.SUPPRESS,
Username: email,
UserAttributes: [
{
Name: 'name',
Value: fullName,
},
{
Name: 'email',
Value: email,
},
{
Name: 'email_verified',
Value: 'true',
},
],
}))
// Set password to confirm user; AdminConfirmSignUp doesn't work on manually created users
await setCognitoUserPassword({ userPoolId, email })
return createdCognitoUser
}
function setCognitoUserPassword ({ userPoolId, email }) {
const password = randomBytes(16).toString('hex')
return cognitoIdpClient.send(new AdminSetUserPasswordCommand({
Password: password,
UserPoolId: userPoolId,
Username: email,
Permanent: true,
}))
}

This handler starts by checking the event.triggerSource to determine if the signup is from an External Identity Provider (e.g. Google) or if it’s with Email + Password. If the signup is with Email + Password and the AUTO_VERIFY_USERS environment variable is set, the account is automatically verified and confirmed. The user doesn’t receive a verification email and is instantly signed in, reducing friction during the critical user registration flow.

If the signup is from an External IDP we first get the provider name from the event.userName property. This property’s value looks like 'google_108986458847054040795', so we split on '_' to get the providerName and providerUserId (the user’s ID within the External IDP). We then lookup providerName in the providerNamesLowerCaseLookup map to get the correct capitalization required by Cognito’s adminLinkProviderForUser API. If no matching provider is found within the map, we throw an error.

Next we query Cognito for an existing account under that Email, and if one exists we simply link the External IDP account to it. Otherwise, we create a native Cognito User with a random password and then link the External IDP account.

Pre Token Generation Lambda Handler

packages/cognito/cognito-pre-token-generation.ts
import { AdminUpdateUserAttributesCommand, CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'
import axios from 'axios'
import type { PreTokenGenerationTriggerEvent } from 'aws-lambda'
import { createUser, getUser, updateUser } from '../api/controllers/user'
import getFullName from './getFullName'
const cognitoIdpClient = new CognitoIdentityProviderClient({ region: 'us-west-2' })
interface Identity {
providerType: 'Google' | 'Facebook' | 'SAML' | 'OIDC' | string
}
export async function handler (event: PreTokenGenerationTriggerEvent) {
const {
userPoolId,
userName,
request: {
userAttributes,
},
} = event
// Don't await here so that we can run the Dynamo and Cognito operations in parallel
const syncUserToDynamoPromise = syncUserToDynamo(userAttributes)
let setUserEmailVerifiedTruePromise
const { identities, email } = userAttributes
if (email && identities) {
const identitiesArray: Identity[] = JSON.parse(identities)
const hasExternalIdentity = identitiesArray.some(identity => ['Google', 'Facebook', 'SAML', 'OIDC'].includes(identity.providerType))
if (hasExternalIdentity) {
// Cognito has a "feature" that sets all attributes to their default values when not present on the external identity provider.
// This results in the email_verified being set to false on each login, which causes features like forgot password to not work.
// Force it back to email_verified=true.
setUserEmailVerifiedTruePromise = setUserEmailVerifiedTrue({ userPoolId, username: userName })
}
}
await Promise.all([syncUserToDynamoPromise, setUserEmailVerifiedTruePromise])
return event
}
async function setUserEmailVerifiedTrue ({ userPoolId, username }) {
return cognitoIdpClient.send(new AdminUpdateUserAttributesCommand({
UserPoolId: userPoolId,
Username: username,
UserAttributes: [{Name: 'email_verified', Value: 'true'}],
}))
}
async function syncUserToDynamo (userAttributes) {
const {
sub: userId,
email,
given_name: givenName,
family_name: familyName,
name,
picture,
} = userAttributes
const fullName = getFullName({ name, givenName, familyName })
const existingUser = await getUser({ userId })
if (existingUser) {
// If the user doesn't have an avatar set and one is available from the external IDP: set it to the user's avatar
// NOTE: Uploading user avatar to S3 instead of storing the base64 in DynamoDB is a better solution.
if (picture && !existingUser.data.avatar) {
const base64EncodedProfilePicture = await fetchAndBase64EncodeImage(picture)
await updateUser({ userId, user: { avatar: base64EncodedProfilePicture }})
}
return
}
const user: any = {
name: fullName,
email,
}
if (picture) {
const base64EncodedProfilePicture = await fetchAndBase64EncodeImage(picture)
if (base64EncodedProfilePicture) {
user.avatar = base64EncodedProfilePicture
}
}
return createUser({ userId, user })
}
async function fetchAndBase64EncodeImage(imageUrl) {
try {
const image = await axios.get(imageUrl, {responseType: 'arraybuffer'})
const base64 = Buffer.from(image.data).toString('base64')
return base64 ? `data:image/png;base64, ${base64}` : null
} catch(error: unknown) {
// If we encounter an error while fetching/encoding the image, it's better to just log and continue.
// The user won't have their profile picture, but at least they'll be registered/logged in!
console.error('cognito.preTokenGeneration.syncUserToDynamo.fetchAndBase64EncodeImage.error', {
errorName: (error as Error).name,
errorMessage: (error as Error).message,
})
}
}

This handler’s primary purposes are to create a user record in DynamoDB if one doesn’t exist (syncUserToDynamo), and to ensure the Cognito user remains verified (setUserEmailVerifiedTrue). Both operations are executed in parallel to improve performance thanks to our await Promise.all.

syncUserToDynamo queries DynamoDB for a User item with the Cognito User’s ID (event.request.userAttributes.sub), and if one doesn’t exist it’s inserted into DynamoDB. If a user already exists but doesn’t have a profile picture set and one is available through the sign in method they’re using, it updates DynamoDB with the profile picture (this happens when they first signed in with Email or SAML which doesn’t expose a profile picture, and then signed in with Google). You could also choose to sync other data here, e.g. if you want to update the user’s name or other data to match what’s in the External IDP.

If the Cognito User has External Identities linked to it, setUserEmailVerifiedTrue is called to force the email_verified attribute back to true. There is an unfortunate Cognito “feature” that sets all User attributes to their default value when signing in with an External IDP that doesn’t return that value. For email_verified this is false. We must keep email_verified=true, otherwise the user won’t be able to sign in with their Email + Password or use the “Forgot Password” flow.

getFullName

A small helper function for getting the user’s full name from the External IDP when it doesn’t support mapping one.

packages/cognito/getFullName.ts
// Google SAML doesn't allow mapping the full name field, so we instead must construct this ourselves based on Given Name and Family Name.
export default function getFullName({ name, givenName, familyName}) {
let fullName = name
// If the name is JUST the given name, AND a family name exists: concatenate them into full name.
// This is especially useful in Google SAML where it doesn't include a full name attribute.
if (givenName && familyName && (!fullName || fullName === givenName)) {
fullName = `${givenName} ${familyName}`
} else if (!fullName) {
fullName = givenName || familyName
}
return fullName
}

End

Ideally, multi-account linking between External Identity Providers and native Cognito Users would be built into Cognito, but thanks to the power of Lambda Triggers it’s something we’re able to implement ourselves.

If you want to build a full stack AWS application that includes functionality like this and much more out of the box, you should try Code Genie 🧞‍♂️