Last updated: Feb 26, 2024
Reading timeยท17 min
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.
It's very common to implement auth with email as a username, unsurprisingly AWS Cognito supports this behavior.
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:
OK, so what's the problem?
The expected behavior would be:
email
attribute in the user
poolWhy did Cognito change my email to john@gmail.com
if I never verified it?
Why am I able to log into my application as john@gmail.com
?
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:
if (user.requestsEmailChange()) { sendConfirmationEmailToNewEmail(); updateUserEmailToNewEmail(); }
if (user.requestsEmailChange()) { sendConfirmationEmailToNewEmail(); } if (user.hasClickedConfirmationLink()) { updateUserEmailToNewEmail(); }
All I can say is that hopefully this gets fixed someday.
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:
You would probably want to customize this email. The way to do this in
Cognito is to use a Custom message
lambda 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:
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?
Your code is 123.
, to say that's
confusing would be an understatementLet's look at the source:
const NICE_ROUND_NUMBER = 20000; if (email.message.length > NICE_ROUND_NUMBER) { return `Your code is ${Math.random().toFixed(4) * 10000}`; }
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.
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.
string
or a number
.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.
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:
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);
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);
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'
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.
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
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.
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
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.
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:
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.
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.
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.
The emails of users who registered with Google / Facebook are not verified by default.
Flip the boolean folks, please. Ongoing feature request for flipping a boolean shouldn't take a year, right?
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:
john@gmail.com
, then they own that 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.
You have 3 SEPARATE, UNRELATED accounts with the email bob@gmail.com - a native Cognito account, a Facebook account and a Google account.
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.
The first time a user registers with an OAuth provider they get an 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:
And then you see the AWS Employee (it's in link number 3):
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.
Speaking of error messages, Amplify throws all kinds of error types and signatures which is very unfortunate because you have to catch these errors.
Promise.reject
with a string, like in their
currentAuthenticatedUser
method:Error
(Error
is a function type in JS), like in their updateUserAttributes
method: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.
They throw various types of errors which bloats your catch block and leads to unhandled errors and bugs
Please, just throw the same error type consistently
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:
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.
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!
You can learn more about the related topics by checking out the following tutorials: