AWS Cognito & Amplify Auth - Service Review

avatar

Borislav Hadzhiev

Wed Apr 21 202117 min read

Updated on Wed Apr 21 2021

AWS Cognito and Amplify Auth are confusing, bugged and with nonsensical defaults - and here are examples

What this article is about #

I'm going to express my dissatisfaction with AWS Cognito and Amplify Auth. If you intend to use these services in the future, or you're already using them, you can probably get something out of reading the article, potentially save yourself some hair pulling.

I'll try to be as objective as I can be in my criticism. I don't have a dog in this race. I don't represent anyone. I use these services every day. If some of these bugs are fixed, I'll be a happy camper.

The change email functionality has been bugged for ~ 3 years #

It's very common to implement auth with email as a username, unsurprisingly AWS Cognito supports this behavior.

email as username

You wouldn't want someone to register with an email they don't own, it's not secure and enables a user to reserve emails they don't own and block the actual email owners. Therefore you would need an email verification step (like every other site on the internet). Cognito also provides this functionality:

verify email

Ok so what's the problem?

  1. The user requests an email change, but doesn't verify the new email with the verification code
  2. Cognito automatically updates the email attribute in the user pool, even though it wasn't verified.
  3. If the user then logs out, they can only log in with their new - not verified email
  4. The new, not verified email is already taken in the user pool, which blocks any users who might have that email from using your website.
  5. The old email the user used is now available, in case someone decides to grab that one.

The expected behavior would be:

Resolved #

  1. The user requests an email change
  2. The user clicks on the link sent to their new email address
  3. AWS Cognito verifies the email and updates the email attribute in the user pool

Rejected #

  1. The user requests an email change
  2. The user doesn't click on the link sent to their email address
  3. AWS Cognito does nothing

Why did cognito change my email to john@gmail.com if I never verified it?

change email bug

Why am I able to log into my application as john@gmail.com?

logged in as john

So I can log in with an email I haven't verified, even though I explicitly selected that I want users to verify their email.

This issue has been open for approximately 3 years.

Let's look at the source and see how we would tackle it:

Default behavior #

if (user.requestsEmailChange()) {
  sendConfirmationEmailToNewEmail();
  updateUserEmailToNewEmail();
}

Proposed changes #

if (user.requestsEmailChange()) {
  sendConfirmationEmailToNewEmail();
}

if (user.hasClickedConfirmationLink()) {
  updateUserEmailToNewEmail();
}

All I can say is hopefully this gets fixed some day, let's move on.


The baffling custom email messages default behavior #

When a user registers, requests an email change, requests a password reset etc, we have to send them an email. The default email cognito sends looks like:

default email

You would probably want to customize this email. The way to do this in cognito is to use a Custom message lambda trigger.

custom message trigger

That's all good, however one day I updated my custom lambda trigger and added a custom html string email template I'd send to my users. After I made the update I tested it and I was still getting the default behavior with the one liner email of type The verification code to your new account is 183277.

So I spent the next 4-5 hours debugging and it turns out the reason for this was that the maximum length for custom email messages is 20,000 UTF-8 characters - docs.

So the way they decided to handle the case where I send 21,000 UTF-8 characters is to ignore my custom message and send their default message, without giving me any indication as to what the cause was.

It's very easy to reach and surpass the limit, especially if you use a templating language to write your emails. So let's say that for some crazy reason, the limit of 20,000 characters made sense,

shouldn't the default behavior be to send you an error indicating the problem?

Instead they send you an email of form: Your code is 123.. And you have to debug a custom cognito trigger and figure out:

Oh, the reason it doesn't work is because I'm sending 21,000 UTF-8 characters and not 19,000, now I understand.

Now I need a custom trigger for my custom trigger to count the UTF-8 characters and alert me if they're more than 20,000, otherwise I'd send a one liner email in production and get fired.

They can change this behavior to throw an error and inform the developer, like tomorrow, and the result would be hundreds of developer hours saved.

What makes this even more confusing is, there are actually multiple reasons as to why they silently ignore your custom email template and send the default one:

  1. Having verification type set to link
  2. Trying to access event.userName
  3. A million of other reasons

So many developer hours wasted for no reason, is it that hard to handle the error and inform the developer?

It makes me wonder who is the target audience of this default behavior, is it the end user or is it the developer?

  • The end user gets a one liner of type Your code is 123., to say that's confusing would be an understatement
  • The developer implements a custom function with a custom email and gets the default one line email. Now you're right where you started but you've wasted a couple of hours.

Let's look at the source:

Default behavior #

const NICE_ROUND_NUMBER = 20000;

if (email.message.length > NICE_ROUND_NUMBER) {
  return `Your code is ${Math.random().toFixed(4) * 10000}`;
}

Proposed changes #

if (email.message.length > NICE_ROUND_NUMBER) {
  throw new Error(
    `For some reason the maximum length of emails is 
    ${NICE_ROUND_NUMBER} and your email is ${email.message.length}
    characters long.`,
  );
}

This would be another easy fix for Cognito. Anyway let's move on.


Custom attributes are a MESS #

When you want to store a property on a user that's not included in the default provided cognito ones, you have to use a custom attribute, i.e. add a boolean isAdmin to your user.

However it's not that simple, because there are huge inconsistencies between the types of custom attributes said to be supported.

  1. Cognito docs and the console say:
  • Each custom attribute can be defined as a string or a number.
  • Each custom attribute cannot be removed or changed once added to a user pool.

custom attributes Console

Okay so I guess custom attributes support only string and number type and I have to be very careful when picking the type, because I can't remove / update the custom attribute later, which means that the only way would be to delete and recreate my user pool.

  1. Cloudformation docs and CDK docs
  • Allowed values: Boolean | DateTime | Number | String

I guess they just didn't implement the boolean and datetime types in the console yet, but they are supported by cloudformation and CDK.

I mean if they aren't supported I'm gonna get an error and my stack will be safely rolled back, right? Let's try:

this.userPool = new cognito.UserPool(this, 'userpool', {
  // ... other config
  {
    myBoolean: new cognito.BooleanAttribute({mutable: true}), // ๐Ÿ‘ˆ๐Ÿ‘ˆ๐Ÿ‘ˆ
    myNumber: new cognito.NumberAttribute({mutable: true}), // ๐Ÿ‘ˆ๐Ÿ‘ˆ๐Ÿ‘ˆ
    myDate: new cognito.DateTimeAttribute({mutable: true}), // ๐Ÿ‘ˆ๐Ÿ‘ˆ๐Ÿ‘ˆ
  },
}

My stack update actually succeeded, let's open the console and see what happened:

custom attributes Console after

So at this point I'm thinking, I guess they implemented the other types as well, they just didn't update the console interface, right? Let's log into our application and see if the types are supported.

First we'll try a custom attribute boolean:

const profileAttributes = {
  'custom:myBoolean': true,
};

return Auth.updateUserAttributes(user, profileAttributes);

custom attribute boolean

Okay, we get an error: TRUE_VALUE can not be converted to a String, I guess booleans are not supported? I mean CDK and Cloudformation both said booleans were supported, the stack update went through with the boolean value, I guess after all they're not supported, too bad I can't update/remove this attribute now.

Let's try with a number, the number type is supported according to CDK/Cloudformation/Cognito docs/Cognito Console. There's no way it doesn't work, right?

const profileAttributes = {
  'custom:myNumber': 42,
};

return Auth.updateUserAttributes(user, profileAttributes);

custom attribute number

So we got an error: NUMBER_VALUE can not be converted to a String.

I can't use a number either? I guess not. But all docs said I could. It turns out the problem is in my code. Look at this solution

All I had to do is wrap my number into quotes, like this '42'

custom attribute string

All you have to do is wrap your number into quotes - '42', in other words convert your number to a string, so that you can use a number type for your custom attributes ๐Ÿ‘

What the number type actually means is - they try to parse your string input as a number and if it fails, it throws an error. You then are responsible to parse the string into a number, for your conditional checks all throughout your application code.

Default behavior #

Cognito docs/console: Custom attributes can be defined as string or a number

CDK / Cloudformation docs: Custom attributes can be Boolean, DateTime, Number or String

Proposed behavior #

Custom attributes are of type string. We provide a number constraint, which tries to parse your string input as a number and if it fails, it throws an error. You are then responsible to parse the string into a number for your conditional checks.

At least this time they throw errors and don't silently decide how to handle things.

Anyway, let's move on.


Amplify Auth's bundle size #

So I just finished building a website, and I ran some checks to analyze my bundle size. I was very surprised to see that the bundle size for my next.js application was approximately ~400Kb gzipped. That's huge, I don't that many external libraries so I started investigating.

It turns our 300Kb gzipped of my 400Kb were from the module @aws-amplify/auth. They were including the same library named bn.js like 7 times - github issue

custom attribute number

Initially I thought that's only 6 instances of bn.js being bundled, but if you look closely, there's a cheeky 7th instance in the right top corner of the node_modules section.

Well this is a little annoying, but it's being worked on by the amplify team, thanks, Eric Clemmons!

Update 17.03.2021 - it seems like this issue has been fixed by the amplify team. I haven't had the chance to try it out yet, but the issue was closed.


Unverified emails of users registering with Google / Facebook OAuth #

I'm going to warn you OAuth with Cognito and Amplify is the worst, so if you have to implement it, prepare mentally.

  • Everyone who ever implemented OAuth with Cognito and Amplify

You need your users to have their email verified, because, otherwise you can't use Forgot Password and some other functionality: why verify email

So on your site you provide a functionality for users to register with Google or Facebook OAuth. Have you ever seen an implementation where you make the users who sign up with Google or Facebook confirm their email? No? Ok, that's the first one.

The default behavior with cognito is:

Amazon Cognito did state by default they assume that any email/phone number they get from the result of a federated sign up or sign in is not verified so they do not set any values for the attribute for the user. Another note, the returned attribute from the IdP also has to have the value be set to the string "true" in order for us to set email_verified to true

So by default they assume that facebook and google emails are unverified. How secure, they don't verify the email of facebook/google users by default, right? But their email change functionality is broken, so it's neither here nor there.

Notice how he also noted that the custom attribute has to be set to the string "true", I guess I'm not the only one getting confused about string-booleans and string-numbers.

In my opinion, if the user has access to a google/facebook account with the email john-smith@gmail.com, then both accounts - the cognito native and facebook/google should be with email_verified set to true.

Let's look at how we can verify the email of a user who registered with Facebook and Google.

Spoiler alert, it's going to be kind of difficult and DIFFERENT, between the different OAuth providers.

Verify a Google registered user's email #

Let's start with Google. You would think that the best way to verify a user's email, would be in the pre-sign-up Lambda trigger. You check if the user who's trying to register comes from an external provider Google, if they do, you know that they're the owner of the email so you set their email verified property to true.

According to the docs, you can verify the email something like:

// Set the email as verified if it is in the request
if (event.request.userAttributes.hasOwnProperty('email')) {
  event.response.autoVerifyEmail = true;
}

The only problem is that autoVerifyEmail doesn't work with identity Providers

Unlucky, buddy, so close.

Anyways eventually you figure it out, you have to provide an attribute mapping between Google's email_verified attribute and cognito's email_verified attribute.

this.identityProviderGoogle = new cognito.UserPoolIdentityProviderGoogle(
  this,
  'userpool-identity-provider-google',
  {
    // ... other config
    attributeMapping: {
      email: {
        attributeName: cognito.ProviderAttribute.GOOGLE_EMAIL.attributeName,
      },
      custom: {
        email_verified: cognito.ProviderAttribute.other('email_verified'),
      },
    },
  },
);

Problem solved, Google was easy money, let's now look at how we can verify a Facebook email, you kind of assume it would be the same for Facebook right? Well, you assume wrong, because Facebook doesn't have an email_verified attribute.

Verify a Facebook registered user's email #

Facebook doesn't keep state of an email_verified property. So you try your best, but you don't succeed, you start to look around for solutions on the internet.

Let's look at the proposed solution from the cognito team for verifying a Facebook registered user's email:

Amazon Cognito invokes Post Authentication trigger after signing a user, allowing you to add custom logic after authentication. Until the feature is released, you can update "email_verified" attribute using "AdminUpdateUserAttributes" API in a Post Authentication trigger which you have already implemented. Please note once the user has sign-up, this trigger will be executed for every future successful sign-in.

Needless to say the "feature" of automatically verifying Facebook user emails never got released.

When you try to verbalize all this, it starts making sense -

In order to verify the email of a user who registered with Facebook, you have to add a Post Authentication lambda trigger. The trigger runs every time the facebook user logs in and verifies their email. Now I understand ๐Ÿ‘

You would think this makes no sense, why don't you just use the Post Confirmation trigger, which runs only after a user has successfully been registered? Well because you'd get a race condition leaving your application in a silently broken state.

Default behavior #

The emails of users who registered with Google / Facebook are not verified by default.

Proposed behavior #

Flip the boolean folks, please. Ongoing feature request for flipping a boolean shouldn't take a year, right?


Cognito / Amplify OAuth - Linking native to external users #

When you provide both OAuth and email registration functionality, a user might register both ways - with their email and with their google account.

So how do you think cognito handles this by default, I mean surely you wouldn't want to have 2 users in your user pool with the same email. That would be very confusing for the user, they log with their email and add an item to their cart, then they log on their phone with google with the same email, and the item is not in the cart.

As you might have guessed cognito doesn't handle this at all, and the default behavior is you just have users with the same email that are not related to one another.

Can you think of a use case for a user having 2 accounts with the same email? No? Ok.

In cognito your email account might have attributes X,Y,Z and Google, or Facebook might not have those attributes on the user object. How would you handle that behavior for your users with 2 separate accounts with the same email in your application.

Let's think about this.

Scenarios 1 and 2:

  1. User has already registered with cognito native and now they create a Google account, with the same email, we should:
  • If there is an email that is equal to the email of the Google OAuth account in the User Pool - Link those accounts.
  • verify their email, if the user has access to Facebook account with email john@gmail.com, then they own that email.
  1. User registers with Google, we should:
  • Create the Google OAuth account in the User Pool
  • Create a native cognito user - email account
  • Link those accounts
  • Verify the email

Now you don't have 2 accounts for the same email and can use user attributes across the different authentication providers - it's a no-brainer. You can manage user properties in your app, i.e. shipping address, city, country, preferences, etc, that you can't access from their google account. This also allows us to enable reset password functionality, in case the user forgets and tries to log in with their email address, everything just works. Wouldn't it be nice for everything to just work?

You don't use a managed auth service to have to implement everything yourself. Why is the default behavior to always delegate to the developer.

The only good reason I can think of to not have your accounts with the same email linked by default is if you don't trust the identity provider requires email validation, that's their excuse - security. If the Identity Provider doesn't require email validation, then I could register with an email I don't own - i.e. bob@gmail.com , I would come register in your application and steal bob's account, because I got linked to it automatically. Well fortunately for us, both Google and Facebook require email validation, so I'm leaning more towards the cognito team just couldn't bother.

Default behavior: #

You have 3 SEPARATE, UNRELATED accounts with the email bob@gmail.com - a native cognito account, a Facebook account and a Google account.

Proposed behavior: #

If a user registered with Google and they have a Cognito email account - link those accounts.

If a user registered with Google and they don't have an email account, create the google account, create an email account and link those accounts.

I'm not going to get into how they handle email change functionality for linked accounts, we saw that they don't handle it for isolated email registered accounts, so I don't feel like beating a dead horse, if you have to implement it - unlucky buddy.


OAuth registration with Amplify #

The first time a user registers with an OAUTH provider they get an error:

oauth registration error

Error: Error handling auth response. Error: Already+found+an+entry+for+username+Google_...

You start looking for a solution and you see some of the issues and hundreds of the developer hours lost:

  1. Cognito Auth fails with "Already found an entry for username"
  2. Integrate facebook/google login to userpool
  3. Unable to log in first-time Cognito User Pool users after a recent change

And then you see the AWS Employee (it's in link number 3):

why verify email

Our plans are to provide built-in support for linking between "native" accounts and external identities such as Facebook and Google when the email address matches.

We do not provide timelines for roadmap items, but I will tell you this is an area under active development.

3 years later this feature still hasn't been added.

Legend has it this feature is still under active development, same as the change email bug fix. Can't give you a timeline right now, but know that if it takes this long it's gonna be good ๐Ÿ‘

Anyway, the way to handle this error is to catch it on your redirect route, i.e. your / route and handle the error, by starting the OAuth flow again, and opening the OAuth window:

const Home: React.FC = () => {
  const router = useRouter();
  useEffect(() => {
    if (
      router.query.error_description &&
      /already.found.an.entry.for.username.google/gi.test(
        router.query.error_description.toString(),
      )
    ) {
      handleGoogleLogin();
    } else if (
      router.query.error_description &&
      /already.found.an.entry.for.username.facebook/gi.test(
        router.query.error_description.toString(),
      )
    ) {
      handleFacebookLogin();
    }
  }, [router.isReady, router.query.error, router.query.error_description]);

  // rest...
};

Hopefully they don't change their error messages because my brittle code would break instantly, unlucky buddy.

All the small things - Amplify's error throwing #

Speaking of error messages, Amplify throws all kinds of error types and signatures which is very unfortunate, because you have to catch these errors.

  • Sometimes they Promise.reject with a string, like in their currentAuthenticatedUser method:

current authenticated user error

  • Sometimes they throw an object that is not instance of Error (Error is a function type in JS), like in their updateUserAttributes method:

update user attributes error

  • Most of the time they throw an instance of Error

I try so hard to catch them all, but in the end I have to read their source code.

You kind of expect to get an error of the same type from the same package. Otherwise you have to check for everything all the time. They throw instances of Error 95% of the time and the just randomly sprinkle misc error types here and there.

Default behavior: #

They throw various types of errors which bloats your catch block and leads to unhandled errors and bugs

Proposed behavior: #

Please, just throw the same error type consistently


The random unexplained errors #

These are the errors you get and you can't reason about, because they make no sense whatsoever, you look at the clock, 5 hours have passed, you've made 0 progress, you're sweating profusely and have had too much coffee, now you won't be able to sleep and you'll have to think about cognito and amplify the whole night.

I'm only going to include 1 of these errors, because they kind of are all the same, not very interesting, once you encounter them you start googling around, if you find something - nice, if you don't - unlucky buddy.

When you have users register with OAuth providers, you can enable attribute mappings. I.e. the Google account first_name attribute to be mapped to Cognito's first_name attribute.

There's this attribute preferred_username, and when you map it using Google as OAuth provider, it works:

this.identityProviderGoogle = new cognito.UserPoolIdentityProviderGoogle(
  this,
  'userpool-identity-provider-google',
  {
    // other stuff..
    attributeMapping: {
      preferredUsername: {attributeName: 'email'},
    },
  },
);

The same attribute mapping, but for Facebook:

this.identityProviderFacebook = new cognito.UserPoolIdentityProviderFacebook(
  this,
  'userpool-identity-provider-facebook',
  {
    // ... other stuff
    attributeMapping: {
      preferredUsername: cognito.ProviderAttribute.FACEBOOK_EMAIL,
    },
  },
);

The only problem is you can't use the facebook mapping, it's bugged and causes an error:

preferred username error

Error: errordescription=attributes+required&error=invalid_request

I would have preferred, if the preffered_username attribute mapping didn't throw a cryptic error for no reason, but it is what it is, five hours later I figured it out.

There are other causes for this error as well, so best believe the select few that encounter it are in for a treat.


End #

Believe it or not there are other things I didn't include in this post, but no one is probably going to read the whole thing so I won't bother.

I use a MANAGED auth service to boost my productivity, well it's NOT working. Spending hours and hours debugging / implementing common sense "features" that should be the default behavior doesn't boost your productivity very much.

My intent with this article is not to mock/offend anyone. My goal is to hopefully see some of these problems fixed in the future. If these teams are understaffed, hopefully they get money to hire more people. I've spent hundreds of hours learning these services, so if I were to cut my losses, these are some significant losses I'd have to cut.

I've tried to be as objective as possible, I don't work for a competitor, I don't have a dog in this race, if cognito and amplify improve - my development experience improves.

If I've misunderstood/misrepresented something it was not intentional and if you correct me, I'll update the article.

If you made it this far, pat yourself on the back, hopefully you're more prepared when you encounter one of these issues. Thank you for reading!

Further Reading #

Join my newsletter

I'll send you 1 email a week with links to all of the articles I've written that week

Buy Me A Coffee