Ensuring Usernames are Unique in Your AWS Amplify App

FullstackPho
15 min readFeb 17, 2020

We’ve all been there. You fill out the entire sign-up form on a web app, hit “SIGN UP”, and you are presented with the error “Username already taken, please select a different one”…

You spend the next few weeks in your basement re-crafting your username, forming shameful amalgamations of childhood nicknames and Greek gods, or witty word-plays on celebrity names. Nonetheless, you end up with one that is embarrassingly sub-par (in your head you’re saying “this username is so not me”) and give up because it’s only a username. Sadly, you’ve just wasted your youth, lost your identity and you no longer have a clear understanding of who you are anymore— all because someone else already had your username.

Checking whether a username exists is a common step in many signup flows, and for good reason. It ensures that two users don’t share the same public identifier, and thus can help prevent users from impersonating each other by ensuring that content and actions are attributable to one account.

With AWS Amplify, you may want your user’s email address to be the primary access key for logging in. If this is the case, then it would be wise to implement a ‘username check’ to make sure each username is unique.

Please note: This tutorial relies on adding user information to a User table in DynamoDB when a user signs up. This is usually considered bad practice in software development, as you have to maintain two ‘sources of truth’. In this case, the User entry in AWS Cognito, and the User table in DynamoDB. It is possible to store some user information as custom attributes inside AWS Cognito. However, it is not possible to run queries for custom attributes across users within Cognito (as far as I’m aware, please comment if you know otherwise!). Irrespective of good/bad practice, this is the only way a “unique username check” in AWS Amplify can currently be executed (correct me!). Hopefully, this article could also be used as a model for other checks during signup.

Summary 📝

In this tutorial, you will learn how to create Lambda Layers, which are useful for setting up a custom runtimes or other dependencies for Lambda functions.

We will code a Lambda function, learn how to test our Lambda function, as well as learn how to set IAM policies so that our Lambda function can interact with a DynamoDB table.

We will also learn how to link our Lambda function to a Cognito trigger, and finally, how to call our Lambda function via our code.

I will assume that you are already familiar with AWS Amplify, and I won’t be going into too much detail into the resources covered here (Lambda, IAM etc), since there is plenty of information out there already on the Wild West Web.

Now then, here is a brief outline of our pre-signup flow…

General Outline 📜

If you select email as your primary access key for your Amplify app, then user submitted emails must be unique by default. Amplify will handle checking these for you.

We have to check that usernames are unique ourselves. This means that we will need to create a pre-signup Lambda function that will run just after the user submits the signup form.

This Lambda will access our User table and execute a query using the provided username.

If the username is found, then it means that the username is not unique and therefore the signup should be aborted, and the user informed that the current username is already taken.

If the username is not found in the database, then the username has not been taken and therefore they may sign up with this username.

Let’s get to it!

Setup 🛠

The first thing to do is to create our react app. We do this using the following command in the terminal:

`npx create-react-app pre-signup-lambda`

Before going any further, please ensure that you have installed and configured the AWS Amplify CLI:

https://aws-amplify.github.io/docs/

Now, cd into the project root folder (`cd pre-signup-lambda`), and run:

`amplify init`

Amplify will guide you through this process by asking some questions. The process is very straight forward, and here are the responses that I used to initialise amplify in my project:

Now, we need to add an API. This will create our DynamoDB table for us. Please note that it is possible to create the DynamoDB table in the AWS console, but by doing it through the Amplify CLI, we will have read/write access via our GraphQL schema. This would be useful later on in our project.

To add the API, run:

`ampify add api`

Again, you will be asked a series of questions. Here are the responses that you should use:

It is important that you select cognito as your default authorisation type, and that you want your users to sign in using email. When you have provided these answers, you will be asked to update your schema now. This is the schema that we will use:

type User  @model  @aws_cognito_user_pools  @key(name: "findUsername", fields: ["username"], queryField:    "findUsername")   @auth(    rules: [      {       allow: owner        ownerField: "id"        operations: [create, read, update, delete]      }    ]  ) {    id: ID    username: String!    firstName: String    lastName: String    email: AWSEmail!    location: String    createdAt: AWSDateTime    updatedAt: AWSDateTime  }

We won’t go into depth about the schema above. It is a very basic implementation of a User schema. You can learn more about the directives here:

https://aws-amplify.github.io/docs/cli-toolchain/graphql?sdk=js

However, there are a few important things to note in the schema above. Firstly, notice that we are using the @key directive. This will create a global secondary index for us called findUsername, where the username field will be our partition key. This means that we will be able to search for our users within the User table using their username. For more info, see:

https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html

We have set the auth rules, using the @auth directive, so that only the owner of the user object can create, read, update, and delete the object. This is for security reasons.

Finally, we have created some other properties (location, firstName etc) on the user object which we will use to store information about them. One of them being the username. This data should be set once the user has signed up. For best practices, it should be done during the signup process using another lambda. If setting this data fails, then the whole signup process should fail, because otherwise you will have a user saved to Cognito that is not associated with a User object in DynamoDB. I will most likely cover how to achieve this in a later tutorial :)

Once you have added the schema above, hit enter in the terminal and your resources will be created.

We don’t need to explicitly add the auth module to our project, since it was added automatically when we created our api. We can check this by running:

`amplify status`

You will see a table like this:

You can see the categories that have been added to our application, then the name of our resources, and the operations that are left to do (which will execute when we push this to the cloud). The creation/update process is handled by CloudFormation. For more info, see here:

https://aws.amazon.com/cloudformation/

Now all that is left for us to do is to push this to the cloud. We do this by running this command in the terminal:

`amplify push`

Select all of the default options that you are presented with, and then amplify will create these resources for us in the cloud! :)

Lambda Layers

AWS Lambda supports multiple languages through the use of runtimes. We will be using Node.js 12.13.0. In order for our function to access AWS resources, it will need access to the aws-sdk node package. It is possible to create, configure and deploy a Lambda function from your local machine using the AWS CLI, but we will be using the Lambda console, as it allows us to cover an interesting tool called Lambda Layers.

Lambda Layers allow you to pull in additional code and content (not just npm packages) in the form of layers. A layer is simply a ZIP archive that contains libraries, a custom runtime or other dependencies. It allows you to use libraries without needing to include them in your deployment package, but also allows you to use a single layer (setup) across multiple Lambda functions. We will be using Lambda Layers to provide node packages to our function.

Getting Started with Lambda Functions and Lambda Layers

The first thing we need to do is to create the npm package that we want to upload on our local machine. Create a new folder to house your Lambda Layer called LambdaLayers (`mkdir LambdaLayers`).

Create a folder inside for the name of your Layer, called PreSignUpLayer (`cd LambdaLayers mkdir PreSignUpLayer`), then inside of this, create a folder called nodejs (`cd PreSignUpLayer mkdir nodejs`). Your folder structure should look like this:

Then, cd into your nodejs folder and run `npm init` to setup your package (select the defaults when asked). Now we can add some dependencies to our package. We only need to the aws sdk, so run `npm install aws-sdk` inside the current folder (nodejs).

Once you have done this, we can zip up our layer so that we can upload it to Lambda. Run `zip -r pre-signup-layer.zip .` inside the nodejs folder. Your nodejs folder should now look like this:

With the contents zipped, we can now create our Lambda Layer in the Lambda console. Open up the AWS Lambda console, and click on Layers on the left hand panel, and then click create Layer:

Fill in the form and select your zipped layer from your hard disk:

It is critical that the runtime is correct, otherwise the layer will not be accessible. Click create.

Now that we have created our Layer, we can create our function 🤖

Adding the Layer to our Lambda Function 🍰

In the AWS Lambda console, click on Functions on the side panel, and then on the orange “Create function” button. Select Author from Scratch, and give your function a name:

Don’t change the permissions just yet (located under the Permissions header), as we will be modifying those later, so the basic permissions will be fine for now. Click Create.

AWS will now create your function, which may take a few moments.

Once your Lambda function has been created, you will be taken to the Lambda configuration screen. The Configuration panel at the top will be open, and this is where we will add our Layer that we created earlier:

Click on Layers below the central block containing your Lambda’s name, and the Layers panel will open at the bottom. Click on Add a Layer in the top right of this panel:

Select your Layer from the list and its version, then click Add:

Your function will now have a layer associated with it:

Hit save in the top right corner. Now let’s write some code!

Coding our Lambda Function 💻

Click on you Lambda function block in the Designer panel, and scroll down to the Function Code panel. We will be using this code editor to add our code. Below is the complete code, which we will walk through in a moment. Give it a read:

// 1. Define and configure packages.const AWS = require("aws-sdk");const dynamo = new AWS.DynamoDB.DocumentClient({ region: `us-east-1` });// 2. Lambda startexports.handler = async (event, context) => {  console.log("Processing event: %j", event);  //3. Set our constants  const UserObjectTableName = "USERTABLENAME";  const IndexName = "findUsername";  //4. Get the username from the event  const username = event.request.validationData.username  console.log("username from event: " + username);  //5. Construct the parameters for our query  var queryParams = {    TableName: UserObjectTableName,    IndexName: IndexName,    KeyConditionExpression: "username = :username",    ExpressionAttributeValues: {      ":username": username    }  };  try {    //6. Check if username exists    const res = await dynamo.query(queryParams).promise();    //7. If the length of Items is not 0, then the username exists!    if (res.Items.length !== 0) {      throw "Username already exists!";    }    //8. Username doesn't exist!    context.done(null, event);  } catch (error) {    //9. Handle errors    console.log("Error!: " + error);    return context.done(error, event)  }};

Although this is quite a long block of code, it’s actually quite simple. Let’s walk through it step by step:

  1. Firstly, we include and calibrate the modules that we will need for our Lambda function. Notice that we are defining these at the top of our file. This is because Lambda functions run in a container, which has to be spun-up in order for the function to run. Containers are often re-used, so by accessing our modules here, we ensure that they are accessible for the next Lambda function. NOTE: The region that you provide for the documentClient should be the same region that your current Amplify CLI profile is associated with!
  2. This is the start of our Lambda function. Notice that it takes two parameters, an event and a context parameter. The event parameter contains data relating to the event that triggered our Lambda function. The context parameter contains meta-information about the request/running Lambda instance. We use this to terminate the function.
  3. Here we are defining the constants that will be used for our query. Use the name of your User table, which can be found by visiting the DynamoDB console in AWS. It will be listed under “Table details” as “Table name”. The `IndexName` must match the name of GSI that we defined within the @key directive in our schema!
  4. Next, we extract the username from the event.
  5. Now we construct the parameters for our query. If you would like to learn more about what is going on here, please visit this web page: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html
  6. Here, we query the User table.
  7. Now we can check if the username exists or not. If the query returns an object, then we know that we have found a matching username, and we need to throw an error.
  8. If we get this far, then the username does not exist (ie is unique) and we can terminate the function.
  9. Some basic error handling.

Before we can test our Lambda function, we need to provide it with the appropriate access policy so that it can query our database. Click save, and then we will do this next.

Adding IAM Access Policies 🔑

Open up the IAM console by switching the tab of the Configuration panel to “Permissions”, and then click the “Manage these permissions” link (highlighted below):

We can now see the policies that have been applied to our Lambda. We currently have only one Policy attached to our Lambda, and that is the LambdaBasicExecutionRole:

On the right of the Permissions Policies table, select “Add inline policy”. Select DynamoDB for the service, click the arrow next to “Read” and select “Query”, and then Add the ARNs for the table and index. These can be found in the DynamoDB overview tab where you found the TableName. Fill out the boxes individually if you are unsure on whether you have found the ARN, as it will construct the ARN for you out of these inputs. Your policy should look similar to this:

Click Review Policy, and give your policy a name:

And then click Create Policy. Now when you go back to your Lambda console tab, select Amazon DynamoDB from the drop down in Resource Summary, and you should see your newly added policy:

Now we can test our Lambda function.

Testing the Lambda Function 📈

In the top right corner of the Lambda console, select the “Select a test event” dropdown and click on Configure Test Events. We are going to create two test events, one that will pass and one that will fail.

First, lets create the pass test event. Give your test a name of PassTest and add in the following JSON:

Click save, then create a second test called FailTest and add the following JSON:

Click save. Now in order to test our Lambda, we need to add some dummy data to the User table. Open the DynamoDB console, find your table, and click the Items tab. Directly beneath this tab, you will see “Create Item”, click this and enter an ID and the username that we used for the FailTest:

Click save, and you will now see the item in your table:

Go back to the Lambda console, and select the FailTest (if it isn’t already selected) from the drop down we used earlier. Click “Test” next to the dropdown, and after a few moments, the Lambda should run, and you should get an execution summary:

Great, so our Lambda has failed as it has searched dDB table and found that our username already exists!

Now select the PassTest, and run the test again, then check the execution result:

Brilliant, so this test has passed as the username does not exist in our database!

So that is the Lambda setup, now let’s look at how we can link our Lambda function to Cognito.

Linking the Lambda to the Pre SignUp Trigger ⛓

In order for our Lambda to be triggered when a user tries to signup, we need to link it to the trigger in Cognito. This will allow it to be called when Cognito detects that someone is trying to signup to our app.

In the Cognito console, click on Triggers in the sidepanel. You should see a whole selection of triggers. The one that we want is the first one on the left called “Pre sign-up”. From the dropdown box, select your Lambda function that we created:

Scroll to the bottom and click save.

Now we can check that our Lambda function works when it is invoked from our client app.

Triggering the Lambda Function from our App 🎣

Now that everything is setup in AWS, we just need to write the code on our client that will invoke the Lambda function.

We will create our own custom signup function that will call the Amplify Auth SDK to try and signup our user, and then handle the custom error that we created in our lambda function.

In the file that you would like to define your function, import Auth from amplify (`import { Auth } from “aws-amplify”`) and then add the following code:

const signup = async (email, password, username) => {  try {    await Auth.signUp({    username: email,    password: password,    validationData: [      {        Name: "username",        Value: username      }    ]  });} catch (error) {  if (    error.code === "UserLambdaValidationException" &&    error.message == "PreSignUp failed with error Username already   exists!."  ) {    error.message = "Username already exists";  }    throw error;  }};

Our function takes email, password and username parameters which should be passed to it from your input element in the signup form of your app (or you can hard code these to test that the function works). Then, we try to signup with the email and password, as well as the submitted username that the user defined. If the username is unique, the signup will be successful. If not, then we will receive an error from our Lambda function. We then modify the error message so that our user can understand it, and then present this to them in our app.

Job done! ✅

If this helped you at all, please give me a clap and feel free to follow me on twitter :) https://twitter.com/fullstackpho

Any comments and improvements are gratefully welcomed! I put this together after trawling through the Amplify docs and GitHub, so if anyone knows of a better approach, please do share!

--

--

FullstackPho

Specialising in full stack development, blockchain, algo trading and nascent tech. Contact me on Twitter or at fullstackpho@protonmail.com