There are only a few things in software engineering I love and hate at the same time - most of the time it’s quite obvious and going strongly in one direction. PHP will always be a bad language to use for anything.

For me AWS Cognito is special and one of those rare cases where I love and hate something at the same time. It’s very ugly, and sometimes still great.

If you don’t know what AWS Cognito is, I wrote a whole blog post about it and when you should use it.

If you are not careful, using AWS Cognito will feel like implementing your very own auth service

This blog post is meant as a dive into the abyss that Cognito can be and help developers to get around the pitfalls you normally run into. It may seem like it’s more on the negative side or that Cognito is bad - that’s not the intention. It’s about educating developers about what they need to know and helping them use AWS Cognito without running into all the possible issues. Let’s make Cognito fun again!

TL;DR: My biggest learnings

If you don’t want to read through all the good and bad parts, I will tell you the biggest problems with using AWS Cognito directly:

The first huge issue: Created user pools (kind of the database for the users) are unchangeable. You can’t edit them. This affects stupid things like usernames (this normally means email addresses!) being case-sensitive by default and also all custom attributes or even which default attributes you did select while creating the user pool. You can change the value of a custom attribute for a user, but you can’t delete or edit the custom attribute itself as defined in the user pool.

Always remember to make the username case insensitive!

The second issue is linking social logins to existing users. If you do not need to do this, don’t do it. Try to link those accounts yourself! Cognito treats a Google Login with “waschi@gmail.com ” as a totally different account (as EXTERNAL_PROVIDER) as a signup and login with username “waschi@gmail.com ” and a password. You can have the same email for two/multiple accounts in AWS Cognito. And that’s actually really bad.

And the third issue: You can’t really search in your user pool - except for simple things like the state and the email. For the email, you can search for a full match or for “begins with”. You can’t even search for custom attributes or chain two attributes. You can’t search for email “waschi@zeile7.de ” in the state “ENABLED”. If you need this, you will need to implement it yourself.

And last and for me the worst issue: No backups. If your user pool with 10 million users gets deleted, it’s deleted. Game over.

That being said, if you know those pieces upfront, you can work with them. How? Create your own additional UUID for users and don’t rely on the one from AWS Cognito and store custom attributes and a reference of each user outside of AWS Cognito - without personal data, only the ID and the attributes you need.

The Good

Implementing your own authentication is always a risk - even if you know the faults of other systems. It takes time, effort and you need to maintain it. You can use an open-source project like Keycloak or use a library from your favorite framework like Laravel or something from Spring Boot , but it requires work and knowledge. And worst: You can make a lot of errors. Especially security-related errors. There will be no nice frontend libraries to handle everything for you.

Using a managed solution on the other hand, like AWS Cognito, has many advantages.

Cheap, easy and reliable

To make it short: It’s cheap. There are quite some managed solutions out there, but they all share one treat: They are expensive as hell. Like Auth0, where you can easily pay 500k€ a year, only for your login! On the other hand, Cognito is basically free for small services with up to 50k unique users a month. You pay on a monthly-active-users basis, with very high quotas for all API calls and admin calls via AWS SDK or CLI.

All solutions, including AWS Cognito, are very easy to integrate and very reliable. Especially if you use the AWS Amplify.js library in your frontend the integration is maybe not a piece of cake, but quite easy and done fast. It comes with bindings/typing for nearly all languages and it just works very well.

Secure, managed and standardized

Let’s be honest: Storing user data and passwords yourself is not the best idea. You can only make mistakes. With a managed service you don’t need to care about the passwords of your users, security protocols, or how it works in detail. It’s very unlikely that AWS Cognito will be hacked. It’s way more likely that your software has some issues, your database is not secured or something else happens.

Do you even know what SRP is? I didn’t. I always just used a form for sending my password to the backend. AWS Cognito defaults to the Secure Remote Password protocol (SRP for your forms, everything is encrypted, you don’t need to care about security.

OpenID Connect documentation with overview of the different parts and technologies used

It’s also based on the most widely used industry standards OAuth2 and OpenId Connect, so no need to worry about what you are actually using. If you don’t know the difference between OAuth2 and OpenId Connect , it may be a good idea to use a managed service. Otherwise, you are bound to make mistakes.

Easy extension with AWS Lambda functions

Cognito is not only easy to use and can be used nearly as a drop-in solution, it’s also easy to extend the functionality. You use already defined hooks and bind a Lambda function to the hook, which will receive events with data. OK, I need to be a little honest: You will need knowledge about how Lambda functions work and how they are deployed.

Here is a nice example of a Lambda function bound to the PreSignup hook with TypeScript bindings. The code is not complete, the detailed called functions are missing. It checks if the signup was done with Google or Facebook, sends a tracking event and triggers a step function to send a reminder email after some days.

import { ... } from 'aws-lambda/trigger/cognito-user-pool-trigger/pre-signup';

export async function handler(event: PreSignUpTriggerEvent): Promise<PreSignUpTriggerEvent> {
  const triggerSource: string = event.triggerSource;
  const email = event.request.userAttributes.email;

  if (triggerSource === 'PreSignUp_ExternalProvider') {
    await handleSignUpWithExternalProvider(event);
  }
  
  await trackSignupEvent(email);
  await triggerReminderStepFunction(event);

  return event;
}

And you can do some awesome things, like Magic Links or things like authentication via QR-Codes. It’s easy to implement - it’s called “custom challenges” within AWS Cognito. OK, easy if you know Cognito, Lambdas and understand the flows ;)

I used it for example to do an auto-login after the email was verified. It’s just one Lambda function away. OK, in this case, I needed three ;) One for creating the magic link, one to verify it and another to determine if there is another challenge or the authentication was successful.

Integration with Social Providers and Active Directory

It comes with a lot of features like MFA (multi-factor authentication), Google/Facebook/Social login, sending e-mails and even SMS. You can also use an e-mail, phone number or any string as a username.

I even once used it at Axel Springer for a small tool where we just used our Microsoft Office365 Active Directory login. No registration or even password is required. It’s awesome for internal tools!

There are some other things, which I usually don’t use. For example, there is a built-in UI and out-of-the-box functionality for double opt-in via email and text messages.

The Bad

If you’ve ever used Cognito you know what I’m talking about. There are tons of moments of rage that you’ve probably experienced during the configuration and integration phase. Everybody who used AWS Cognito for the first time will most likely swear a lot. Most boards and Reddit are full of people crying about AWS Cognito and how beta it often feels.

If it’s created, it’s unchangeable

I already mentioned it: A created user pool is not changeable. You can’t change which attributes are mandatory if the username is case sensitive or any other attributes of the user pool. This also affects custom attributes - not changeable. You can add new ones, but you can’t delete or edit existing ones.

You would need to create a new user pool and migrate all users on login! Yes, you can’t just migrate all users, you will need to write the migration logic yourself. And one thing to mention: It’s really a huge effort to migrate one user pool to a new user pool.

Sometimes it feels like beta

I don’t know how many people work in the AWS Cognito development team, but they are not very fast and new features have always tons of bugs. Some things feel like it’s still in beta and not fully released or like it’s a preview version. Some of my examples may already be outdated and fixed but were still existing at the beginning/mid of 2021.

Dilbert comic from https://dilbert.com/strip/2009-07-01

For example custom attributes. It’s not only that the naming is inconsistent, types do not really work (only ‘string’ works), but also required/mandatory does not work. Yes, Cognito supports custom attributes, but very rudimentary. You can’t search for them, they are only strings and sometimes quirky.

You can’t use some of the triggers/hooks over terraform or the AWS console, they are only usable with the CLI. And obviously, they get overwritten if you do changes elsewhere - you can only set all hooks at once - CDK/Terraform/CloudFront will just remove your hook you did set via the AWS CLI. And the AWS CLI command also needs to set all hooks with one call, which makes the call very complicated.

Some features are not supported via the AWS Console and some are not supported via Terraform, but only via the AWS CLI. Like token validity for different types of tokens.

There is a race condition on the Lambda Triggers for PostConfirmation and PostAuthentication. We seemed to also have a race condition with the linking of a Social Provider (called external providers within AWS Cognito) to native (username with password) accounts when we did the linking in the PostConfirmation hook.

And sometimes we had strange bugs with Social Providers and the mappings of their internal attributes to AWS Cognito attributes. Like the first name coming from Google and trying to map it to the correct attribute in AWS Cognito.

Error handling and error logging

AWS Cognito does not have any kind of internal logging or information on errors at all. The metrics are very basic. There are only metrics on successful events. To be fair in general AWS Cognito is very stable, but the more users you have, the more you see edge cases and not reproducible one-time errors. We mitigated this with retries, which worked.

Only successful events are within the CloudWatch metrics and no logs

If you use a tool like sentry.io to log errors in your frontend application, you will sometimes see strange errors coming from AWS Cognito. Small hiccups. For example, I saw cases where AWS Cognito tried to validate the token it got as a query parameter from a Google login. But AWS Cognito could not reach the Google server, for whatever reason, and had a timeout after 5s. The frontend client got back an internal server error from AWS Cognito, which didn’t include much information.

Handling error messages is kind of trial and error and they are either too detailed or too generic

But: You will need to handle all errors in the frontend yourself, there is no nice way or overview of possible errors. It took quite a long time to find, catch and properly handle all error states! Even looking into the source code of AmplifyJS is not enough, as most errors are just passed through. I actually needed to parse the message of the error itself to decide on proper error handling. AmplifJS is very easy and does not need much code, but the error handling for all error states for login and registration attempts takes massive work.

Sparse documentation

Yes, everything is documented. But not in detail - especially information connecting the dots is missing. Or about error states. You will need to reverse engineer a lot of stuff, especially if you use hooks / Lambda functions. There is also not much about edge cases and limitations - try finding information about all the issues with Social/External Providers.

I would call it existing, but bad documentation. Like there are not many examples of the data structure for the extension hooks or when they fire and when not. For example the first login with an external provider like Google - it counts as SignUp, does not trigger Pre/PostAuthentication, but triggers PostActivation. You will need to reverse engineer the behavior of AWS Cognito in some places and it can get confusing.

Another example: Try to send custom emails. The documentation mainly mentions the options to have custom templates. What it doesn’t tell you is that this is limited in characters (not enough for HTML emails) and also has some other limitations. And the alternative is only slightly mentioned in one half of a sentence - disabling the sending of emails and handling it completely on your own. Have fun figuring out the details.

Working with hooks and AWS Lambdas

This one is a good thing and a bad thing at the same time. Yes, it’s cool that you can extend the functionality. I also love Lambda functions - but you need quite some knowledge about them. This is a very steep learning curve! If you did it once, it feels awesome and easy but you will need to wander through a valley of sorrows first.

What irritates me the most: It’s not consistent. There is a PreSignUp hook, but no PostSignUp hook. This sucks, as there are cases where the SignUp fails and you want to do some actions for signups.

There are various other pitfalls you will run into: You can’t really manipulate data within the events. So if you want to adjust some attributes in those events, you will need to call the AWS SDK to manipulate users directly. For example, you can lower case the username in a PreSignUp event and there is no PostSignUp event. So how can you manipulate or enrich a newly registered user? Only when he does a login in the PostAuthentication event by calling the SDK.

Declining events always result in exceptions and you need to handle the errors yourself. AWS Cognito will return an Internal Server Error with your error as the message. This is annoying especially for the UserMigration lambda - it has only two states, user found (returns user data and imports the existing user) or user not found (returns null and creates a new user). But what if the user is disabled or not activated yet? You can only throw an error and parse it in the UI to tell your user that he’s disabled or that he needs to activate his account first.

Sending custom emails

I already said it before: The documentation about custom emails is not very detailed. Custom e-mails will be a lot of work. You can in theory (and like the documentation tells you) just use message templates via the CustomMessage Lambda hook. It’s possible, but the maximum length for custom email templates is 20,000 UTF-8 characters - which is not enough for HTML based emails.

The really bad: 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 any indication as to what the cause was.

To fix this you will need the CustomSender hook, which is new and was not available in the AWS Console or via Terraform (the link also contains my example on how I did it, check it out). You would need a complicated call to the AWS SDK to set it, together with all other Lambda functions. It was introduced around March 2021, before that you had no easy option for sending custom e-mails. As a reminder: AWS Cognito was introduced in 2014.

Smaller complains

There are some places where AWS Cognito is just not flexible enough or does not make sense. Like somebody didn’t think everything through. I give you some examples:

The e-mail address from a Social/External Providers will be by default “verified=false” but setting autoVerifyEmail doesn’t work with identity providers. Well, better not rely on the verified flag.

If you use the password forgotten flow, it will never tell you if any email was sent or triggered (even with custom emails). The API call always returns 200. If you want any kind of check, you will need to build this completely custom with custom verification tokens.

If you want to use the same login across multiple domains or subdomains you need cookie based storage. The issue is, that this has a high payload - the OAuth2/OpenId Connect payload in form of cookies is up to 2 kBytes, which is very heavy. Most servers only accept 4 kB or 8 kB for all headers combined. And AmplifyJS sometimes does not clean up its old cookies if you login with multiple users, so in our development environment I got strange 500 errors which I did not understand. Reason: The header payload exceeded the allowed server limit.

Changing emails as a user is a mess . You will most likely want to implement it yourself or live with a very bad flow with no double opt-in for changing emails.

The Ugly

I will now tell you about the darkest parts of AWS Cognito. Things so bad, you will reconsider using AWS Cognito. You will need to know about this, otherwise, you may end up in a very dark place. I still recommend using AWS Cognito, but you need to know what you are getting yourself into.

I hope you saw this classic movie

Linking user accounts to social providers

Users with username and password users and Social/External Provider users are treated as different entities. 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 login with their email address first and later they login may be on their phone with Google with the exact same e-mail address?

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.

When you provide both social logins and email registration functionality, a user might register both ways - with their email and with their Google account. You will need to handle all states, cases and the linking manually. You can, in theory, link the External/Social Provider to a “normal” user account.

But: You can only link Social/External providers to existing user accounts, not the other way around. This means if a user did his first sign-up/sign-in with Google, you can’t link that account anymore. And you can only link them in a PreSignUp event before the Social/External provider is actually used. And now the really ugly part: Linking users in a PreSignUp event fails the login attempt and requires a repetition of the actually sign-in with Google.

Obviously, with AWS Cognito you can’t set a password for users from Social/External Providers, as Cognito treats them differently. And you can’t link them anymore. What do you do if you have a user registered with Google who wants to set a password? Very easy: You just delete the user and create a new user via the AWS SDK with username and password. If the user tries to login with Google again, you now can link that account. But the first login will fail, as mentioned above. WTF.

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

Case sensitive by default

The username can be made case insensitive, I have no idea why this isn’t the default option or why it’s even an option. But, if you forget to enable this you’ll end up with users like MyEmail@talkncloud.com and myemail@talkncloud.com as two different users. It’s nuts! to enable this after the pool has been created you’ll need to recreate the pool… Or hack a lot of toLowerCase() calls into all places of your code. Yes, I did this once and I’m still crying. It caused so many bugs. I’m sorry.

No backups, no multi-region

I worked on many disaster recovery plans and backup solutions. It’s really important if you want to continue your business after any disaster. AWS Cognito makes it very easy to fuck things up, especially if you use Infrastructure-as-Code tools like Terraform. If you forgot to enable the deletion protection of your AWS Cognito User Pool any change could trigger the deletion and re-creation of the whole user pool! Yes, changing names or defaults would delete all your damn users in an instant without a backup.

It’s actually hard to only export your users - you will need to write a do-while loop loading 60 users (the maximum possible per call) per API call and export them manually. This can take ages if you have millions of users! And: The passwords can’t be exported, so if anything happens, all users would need to use the password forgotten function.

Migrating users from your legacy systems

This first sounds very easy: You have a specific hook and just need to write one Lambda function which always checks if the user exists or not. It also looks well documented with multiple examples.

The reality: So many edge cases thanks to how AWS Cognito handles External/Social providers or how it is strange in some places. If you use Social Providers, the migration will be an absolute mess. Obviously, you can’t import them, so you need to throw an error like “You need to use your Facebook to login” and handle it completely manually in your frontend, as with AWS Cognito it will be an Internal Server Error (logged nowhere).

The user migration flow actually requires quite some effort as the triggers contain multiple event sources which require dedicated handling. There is a signup and a password forgotten flow and both also contain the case that a user did register with a Social/External Provider.

One big problem: The migration flow does not cover states where the user is locked, disabled or not activated. And the UserMigration Lambda only knows two results: User found (import it) or user not found (create a new user).

The whole flow and approach are so complicated, it would require a lot of additional programming, knowledge and documentation.

Searching and finding users

Aside from the fact that the UI for the AWS Console for finding users in AWS Cognito is bad, the function overall is very limited. Searching and listing accounts are nearly impossible.

There is no real API for searching, you just use the ListUsers function. It contains rudimentary filters, which are string-based. There is no validation of your filter, the call always works, so you need trial-and-error to find out if you made a mistake. You can’t search for custom attributes. You can’t combine two filter criteria with AND/OR, but if you do, the call still works. You can’t search for email=XXX AND status=‘CONFIRMED’. It only works with a full match or by the prefix. Overall, it’s totally useless.

Should I use AWS Cognito?

Look, I wrote a whole post only about that . The short answer is: It depends. For any new project, definitely yes. For any internal tool, also a clear yes. For anything legacy with a lot of users and use cases: Only if you already have enough experience with AWS Cognito.

Additional read-ups: