AWS Cognito & Amplify Auth - Bad, Bugged, Baffling

avatar
Borislav Hadzhiev

Last updated: Feb 26, 2024
17 min

banner

# AWS Cognito & Amplify Auth - Bad, Bugged, Baffling

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 and 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 that hopefully this gets fixed someday.


# 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 as follows:

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 with the 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 that 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 other reasons

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

It makes me wonder who the target audience of this default behavior is, 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.


# 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 types 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 was 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 a 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 for parsing 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 use that many external libraries so I started investigating.

It turns out 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 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 with something similar to this.

// 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 signed-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 in 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 that 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 the Facebook account with the 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, 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 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 = () => { 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 an 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 they 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!

# Additional Resources

You can learn more about the related topics by checking out the following tutorials:

I wrote a book in which I share everything I know about how to become a better, more efficient programmer.
book cover
You can use the search field on my Home Page to filter through all of my articles.

Copyright ยฉ 2024 Borislav Hadzhiev