Add a user to an Amazon Cognito group using AWS Lambda

AWS Lambda is one of the first services that cloud developers learn about. It is a powerful service that allows you to run code without provisioning or managing servers. It's often coupled with API Gateway to create a REST API, or with AWS AppSync to perform custom logic. Personally, I've always felt this messes up the mental model of what AWS Lambda is for: A function that is called when an event occurs.

Sure, that can be an API, but removing it from the API mental model, opens up the door to other use cases.

To demonstrate this, in this post I will show you how to add a user to an Amazon Cognito group with a post-confirmation trigger.

The Use Case

Let's say you have a web application that allows users to sign up and create an account. However, you want to tailor their experience based on the role they select in a dropdown during signup. So someone who signs up as a "marketer" gets added to the marketing group, and someone who signs up as a "developer" gets added to the developers group.

This is a perfect use case for a post-confirmation trigger.

πŸ—’οΈ If wanting to just see the full solution, you can find the complete code on GitHub.

Understanding the Post-Confirmation Trigger

Amazon Cognito comes with the ability to be alerted when certain events occur in the user lifecycle. To be alerted, you assign a trigger to the specific event you care about. This trigger is simply a Lambda function.

Cognito comes with several triggers, but honestly, the one I use the most is the post-confirmation trigger which is triggered when a user is confirmed after a user is fully created with email/password signup.

πŸ—’οΈ If using social signup (Google, Facebook, etc.), the post-confirmation trigger is not triggered since authorization is handled by the social provider. Reach out if you'd like to learn more about how to handle this.

The Solution

πŸ—’οΈ While this solution uses AWS Amplify for as the infrastructure-as-code tool, the same logic and concepts can be applied to any AWS integration.

Let's break down the solution into a few steps:

  1. Setting up Amazon Cognito
  2. Configuring the post-confirmation trigger
  3. Integrating the frontend with the backend

Setting up Amazon Cognito

Due to AWS Amplify being tailored for fullstack development, running npm create amplify will scaffold a backend with both data (AWS AppSync and DynamoDB) and auth (Amazon Cognito). While the data category isn't relevant to this post, I've found the auth category to be surprisingly flexible.

The following is all that's needed to setup Cognito with a user pool, identity pool, Cognito groups, email/password signup, along with standard and custom attributes:

export const auth = defineAuth({
  loginWith: {
    email: true,
  },
  groups: ['founder', 'developer', 'designer', 'marketer'],
  userAttributes: {
    fullname: {
      required: true,
    },
    'custom:categoryName': {
      dataType: 'String',
      mutable: true,
    },
  },
})

Configuring the post-confirmation trigger

Setting up the post-confirmation trigger is a two-step process:

  1. Create the Lambda function
  2. Configure the Cognito to use the Lambda function as the post-confirmation trigger

Creating the Lambda function is straightforward. I created a new file called resource.ts inside the amplify/functions/addUserToGroup directory. Note that the directory structure isn't important and just Amplify's convention.

In the resource.ts file, I added the following code:

import { defineFunction } from '@aws-amplify/backend'

export const addUserToGroup = defineFunction({
  name: 'add-user-to-group',
  resourceGroupName: 'auth',
  entry: './main.ts',
})

This is all that's needed to create the Lambda function. The entry points to the main.ts file, which is where the function logic will be added. The resourceGroupName simply says, "Deploy this Lambda function in the auth resource group." which is nice to avoid circular dependencies.

Next, I added the following code to the main.ts file:

import type { PostConfirmationTriggerHandler } from 'aws-lambda'
import {
  CognitoIdentityProviderClient,
  AdminAddUserToGroupCommand,
} from '@aws-sdk/client-cognito-identity-provider'

const client = new CognitoIdentityProviderClient()

export const handler: PostConfirmationTriggerHandler = async (event) => {
  const groupName = event.request.userAttributes['custom:categoryName']
  const command = new AdminAddUserToGroupCommand({
    GroupName: groupName,
    Username: event.userName,
    UserPoolId: event.userPoolId,
  })

  await client.send(command)
  return event
}

This is the actual logic for the Lambda function. It uses the @aws-sdk/client-cognito-identity-provider package from the AWS SDK to add the user to the group.

When I first started developing with AWS, this was the "aha" moment in how powerful Lambda functions are. They're not just for API endpoints.

Because this function is going to be triggered by Cognito (specifically after a user is confirmed), it's important to note that the event object is not the same as the event object in an API, but instead contains properties relating to the newly confirmed user.

The event.request.userAttributes contains the attributes that were added to the user. In this case, I'm looking for the custom:categoryName attribute.

With the function created, I head back to the amplify/auth/resource.ts file and add the following code to the auth resource:

// Previous code...
	userAttributes: {
		fullname: {
			required: true,
		},
		'custom:categoryName': {
			dataType: 'String',
			mutable: true,
		},
	},
  // New code...
	triggers: {
		postConfirmation: addUserToGroup,
	},
	access: (allow) => [allow.resource(addUserToGroup).to(['addUserToGroup'])],

Without that code, we'll have a Lambda function that is deployed but never called.

The triggers property is where we tell Cognito when to use our Lambda function.

The access property is where we permit our Lambda function to call the addUserToGroup function from Cognito.

With that, we're all set up to add a user to a group when they sign up once we add our Lambda function to our backend stack of resources. In the amplify/backend/resource.ts file, we'll add the following code:

defineBackend({
  auth,
  addUserToGroup,
})

πŸ—’οΈ To deploy the backend, run npx ampx sandbox.

Integrating the frontend with the backend

Our project is scaffolded with Vite (React), along with React Router and uses DaisyUI for styling.

Additionally, it's using the @aws-amplify/ui-react package to give us a production-ready signup component.

While the routing and auth config is handled in the App.tsx file, the star of the show is actually the Protect.tsx file.

Here I wrap the Authenticator component and provide a custom SignUp component that has a Full Name field listed at the top, and a Category field listed at the bottom. Notice in the select field, I'm using the custom:categoryName attribute. This is how the connection is made between what the user selects and the attribute that will be used to add the user to the group.

<Authenticator
  className="bg-base-200 flex h-screen flex-col items-center justify-center"
  formFields={formFields}
  components={{
    SignUp: {
      FormFields() {
        return (
          <>
            <Authenticator.SignUp.FormFields /> // Existing form fields
            <SelectField
              label="Category"
              name="custom:categoryName"
              descriptiveText="How do you best describe yourself?"
              required
            >
              <option value="founder">Founder</option>
              <option value="developer">Developer</option>
              <option value="designer">Designer</option>
              <option value="marketer">Marketer</option>
            </SelectField>
          </>
        )
      },
    },
  }}
>
  {children}
</Authenticator>

We now have all the pieces in place to have a working solution. To verify, the /secondary route is protected and requires authentication. Once a user signs up we'll display their full name and category.

To accomplish this, we'll fetch the current user session using the fetchAuthSession function from Amplify. With this, we can get the full sign in details including the user's full name and category.

import { fetchAuthSession } from 'aws-amplify/auth'
import { useEffect, useState } from 'react'
export function SecondaryPage() {
  const [userGroup, setUserGroup] = useState('')
  const [username, setUsername] = useState('')

  useEffect(() => {
    fetchAuthSession().then(({ tokens }) => {
      const idTokenPayload = tokens?.idToken?.payload
      console.log(idTokenPayload)
      const groups = idTokenPayload?.['cognito:groups'] as string[]
      if (groups) {
        setUserGroup(groups[0])
      }
      const name = idTokenPayload?.['name'] as string
      if (name) {
        setUsername(name)
      }
    })
  }, [])

  return (
    <div className="bg-base-200 flex h-screen flex-col items-center justify-center">
      <h1 className="text-4xl font-bold">Welcome {username}</h1>
      <p className="text-2xl font-bold">You are a {userGroup}</p>
    </div>
  )
}

πŸ—’οΈ There is a useAuthenticator hook that can be used to get the current user and sign them out, but the most you can get is the user's login ID (email).

Conclusion

In this post, we learned how to add a user to a Cognito group with a post-confirmation trigger. By automating this process during signup, we can allow users to have a tailored experience based on the group they are in.

A core concept here is understanding that Lambda functions are not just for API endpoints. They can be used for any event-driven logic.

I hope you enjoyed this post. If you have any questions, please reach out on X or LinkedIn.

Also consider joining my newsletter where I share posts/videos you might've missed as well as things that caught my eye in the community! 🦦

Stay up to date

Get weekly updates on new posts and projects. I promise not to spam!