How I Built an AWS App Without Spending a Penny — Cognito

Abhishek Chaudhuri
13 min readNov 25, 2023

--

AWS logo with dollar sign crossed out

This is part 7 of a now 7-part series. See the previous parts where we built the frontend, backend, and pipeline.

In the past 6 parts, I referred to the following diagram when designing my cost-effective AWS app:

AWS architecture diagram for a sample full-stack web app
From Architecting Serverless Applications

But there was one service I neglected to talk about until now: Cognito. Cognito has a generous free tier, but when I was writing the previous 6 parts, I didn’t have a clear use case in mind. The infrastructure isn’t too difficult to set up, but most of Cognito’s work is done in the client app. I wanted to make sure I came up with a justifiable use case for Cognito in my app so it would allow me to experiment with as much of the service as needed.

Cognito is an AWS service that handles user authentication and authorization. If you’re familiar with login pages, that’s what Cognito is designed for. It has 2 main offerings: user pools and identity pools. It’s important to understand the difference between these two (especially if you’re planning to take the Developer Associate exam). User pools let you manage the sign-up/sign-in functionality, manage user profiles, and provide OIDC-compliant JSON Web Tokens (JWTs), while identity pools provide temporary credentials to access AWS services (access key ID, access key secret, & session token). This is not to be confused with IAM. IAM roles designate access for teams that need to access an AWS account, while Cognito designates access for any user using a public-facing client application.

If you imagine the sign-in page again, user pools are first used to allow users to either sign in or sign up using any identity provider. This can be Cognito itself, but it can also be any OAuth-compliant provider like Google, Microsoft, or even Amazon. Cognito also supports SAML-based providers, such as Active Directory. Once the user is signed in, let’s say we want to give them access to their profile. The profile information is stored in a database and we want to expose an API to retrieve that data for authenticated users. When the user signs in to the user pool, they are given a JWT. We can then pass this JWT to API Gateway (or AppSync if we’re using GraphQL) to determine if the user is authorized to view their profile. In this case, an identity pool isn’t needed.

But what if, instead, we wanted to give authenticated users access to files under their account? With an identity pool, we can exchange the token for temporary AWS credentials so users can access objects stored in S3. These credentials are the same ones used to make API calls to AWS. Under the hood, Cognito makes calls to STS (Simple Token Service) to handle this exchange. This is similar to assuming a role in IAM to access certain resources, but in this case, we provided the access using a public-facing method (i.e., a sign-in page). IAM is the equivalent of signing in to the AWS console directly. So, the choice of pools depends on how many security layers you want to create between the user and your AWS resources.

Cognito identity pool sample workflow
Cognito identity pool sample workflow, source: https://docs.aws.amazon.com/cognito/latest/developerguide/authentication-flow.html

In terms of pricing, Cognito is very generous for personal projects. Identity pools are completely free and user pools are free for the first 50,000 MAUs (monthly active users), as long as you don’t enable advanced security features. For this project, I opted out of using an identity pool and stuck with a user pool since I felt the JWT was satisfactory for authentication and I didn’t need to give the user direct access to the AWS API.

Another reason why I initially held off from using Cognito was because of its infamous reputation. If you look up the worst AWS services, chances are you’ll see Cognito on many people’s lists. (For example, here’s a Reddit post that lists many reasons why Cognito & Amplify are bad.) Many of them can be attributed to Cognito lacking features (without utilizing things like Lambda) or confusing documentation. For my app, I didn’t go too far in-depth with juggling multiple users/providers or handling custom attributes. But as with the rest of AWS, there will be gotchas along the way as you build your app, which can help reinforce your learning.

Creating the Infrastructure

To create a user pool, we add the following to our CloudFormation template:

CognitoUserPool:
Type: AWS::Cognito::UserPool
DeletionPolicy: Delete
UpdateReplacePolicy: Delete
Properties:
AccountRecoverySetting:
RecoveryMechanisms:
- Name: verified_email
Priority: 1
AdminCreateUserConfig:
AllowAdminCreateUserOnly: false # change to true if testing
AutoVerifiedAttributes:
- email
DeletionProtection: ACTIVE
EmailConfiguration:
# Default email: no-reply@verificationemail.com
EmailSendingAccount: COGNITO_DEFAULT # max 50 emails per day
# Default password policy: 8 chars w/ 1 num, special char, uppercase & lowercase letter
MfaConfiguration: "OFF" # turn on or make optional for more sensitive apps
UserAttributeUpdateSettings:
# Don't update email addresses until they're verified
AttributesRequireVerificationBeforeUpdate:
- email
# Allow users to sign in using either their username or email
UsernameAttributes:
- email
UsernameConfiguration:
CaseSensitive: false
# Use the Cognito Hosted UI for authentication
CognitoUserPoolDomain:
Type: AWS::Cognito::UserPoolDomain
Properties:
Domain: !Ref AuthDomain # .auth.[region].amazoncognito.com
UserPoolId: !Ref CognitoUserPool
CognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
AccessTokenValidity: 1 # TokenValidityUnits = hours
AllowedOAuthFlows:
- code
AllowedOAuthFlowsUserPoolClient: true
AllowedOAuthScopes:
- openid
- phone
- email
- profile
- aws.cognito.signin.user.admin
AuthSessionValidity: 3 # session token validity in minutes
CallbackURLs:
- "http://localhost:5173" # dev
- "http://localhost:4173" # QA
- !Sub "https://${CloudFrontDistribution.DomainName}" # prod
EnableTokenRevocation: true
ExplicitAuthFlows:
- ALLOW_REFRESH_TOKEN_AUTH
- ALLOW_USER_SRP_AUTH # SRP = Secure Remote Password
GenerateSecret: false
IdTokenValidity: 1 # hour
PreventUserExistenceErrors: ENABLED # shh, don't tell hackers a user doesn't exist
RefreshTokenValidity: 30 # days
SupportedIdentityProviders:
- COGNITO
UserPoolId: !Ref CognitoUserPool

It consists of 3 parts: the user pool itself, a domain, and a client. The user pool defines how users can authenticate. All users have a username to uniquely identify themselves. For added security, you can ask users to verify their email or phone number. In this case, I require users to verify their email. They will use their email to sign in and the username is automatically set to a UUID. Cognito provides a preferred_username attribute that can be used to let users set their username without requiring it to be unique. When sending the verification email, you can either use Cognito’s default email (COGNITO_DEFAULT) or configure the email yourself (DEVELOPER). For real apps, it’s recommended to use DEVELOPER mode since Cognito has a limit of 50 emails per day. This requires SES, and while it has a free tier, I felt the default was satisfactory for a personal project with not too many users.

One important setting to enable is AttributesRequireVerificationBeforeUpdate. This fixes one of the big complaints people had with Cognito for several years. Whenever users change their emails, Cognito by default updates the email attribute before verifying their new email. This is a big issue for several reasons:

  • If the user accidentally mistyped their email, they could potentially be locked out of their account.
  • Malicious users can change the email to someone else’s account and cause potential spam or unexpected costs.
  • It would also prevent this user from registering their account since their email would be “taken”.

Depending on how sensitive the app is, it’s also recommended to enable MFA so users can secure their accounts. But again, I felt this wasn’t needed for a simple app like this. Another noteworthy setting is AllowAdminCreateUserOnly. If this is false, anyone on the internet can sign up for an account. If this is true, only Cognito admins can create accounts. If you’re not ready to release your user pool in production, make sure to set this to true for testing.

Cognito comes with standard OIDC attributes, such as address and birthdate. But if you want to add custom attributes or make any of these attributes required, you can specify a Schema. Keep in mind that required attributes can only be set when first creating the user pool. You must delete the user pool if you want to modify or delete any required or custom attributes.

The user pool domain defines where users can sign in. Cognito offers a hosted UI for handling user authentication, including signing up, changing your password, and verifying your account. You can customize the styling using AWS::Cognito::UserPoolUICustomizationAttachment or create a custom domain to have greater control over the authentication process.

Cognito hosted UI
Cognito hosted UI

For this template, the domain name comes from a parameter that checks to make sure the pattern is valid. (I learned about reserved words the hard way when I initially tried setting the domain as aws-shop. 😅)

AuthDomain:
Type: String
Description: The domain name for Cognito's hosted UI
# aws, amazon, and cognito are reserved words
AllowedPattern: "^(?![\\s\\S]*?(?:aws|amazon|cognito))^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$"

Finally, the user pool client defines OIDC-specific attributes for the user pool. If you’re not familiar with OAuth, OIDC, or any specific terminology, I recommend checking out this guide to learn more about the process. For this app, I let Cognito be the identity provider, but you can list any other providers you want to support. To follow the best practices, I utilize an Authorization Flow with PKCE. The user will first sign in using their credentials and get back a code. They will then exchange this code for an ID token, access token, and refresh token. The ID and access token are valid for 1 hour, while the refresh token is valid for 30 days so it can refresh the other tokens when they expire. These are the defaults set by Cognito, but feel free to customize these settings based on your security requirements. Cognito also generates a session token when the user is authenticating and responding to challenges, such as with MFA. The default is 3 minutes, which should be enough time since the user only needs to provide their email and password.

I’ll talk more about the scopes when I get into how I integrated Cognito into the web app, but in this case, I enabled all 5 scopes provided by Cognito. I set 3 callback URLs. These correspond to the dev and QA environments provided by Vite and the prod environment set by our CloudFront distribution (see part 2 if you need a refresher). We enable token revocation so tokens can become invalid whenever the user signs out. For auth flows, we allow tokens to be refreshed and utilize SRP authentication. SRP (or Secure Remote Password) is an additional security layer to validate the user’s password and is recommended over the legacy USER_PASSWORD_AUTH flow in Cognito. Since the web app will handle user authentication, we should not generate a client secret since it can’t be secured on the front-end. A client ID will suffice. Lastly, if the user enters the wrong credentials, a good security practice is to vaguely tell the user their credentials are invalid. It shouldn’t provide details about whether the username exists or if just the password was invalid since this can help hackers deduce the information needed to sign into someone else’s account.

Cognito user pool sign-in flow
Cognito user pool sign-in flow, source: https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow.html

Once the user pool is set up, you can view the user pool ID and client ID, which will be needed when calling the APIs. You can create a user and test signing in using the hosted UI. If you view the network logs, you can get an idea as to how the APIs are set up. I won’t talk about how to perform this integration specifically in a React Vite app, but I’ll give a general overview that can be applied to a web or mobile app.

Side note: If you’ve been following along since part 1 (thank you by the way 😊), I had to make the following adjustments in the main CloudFormation template to accommodate Cognito. First, I gave the GitHub Actions role admin access to Cognito for the pipeline:

Resources:
GitHubRole:
Properties:
ManagedPolicyArns:
- "arn:aws:iam::aws:policy/AmazonCognitoPowerUser" # 52

Then I modified the content security policy to accept connections from the hosted UI and Cognito IdP (identity provider), using variables to dynamically set it based on the user pool configuration:

Resources:  
CloudFrontResponseHeadersPolicy:
Properties:
ResponseHeadersPolicyConfig:
SecurityHeadersConfig:
ContentSecurityPolicy:
# Test using Content-Security-Policy-Report-Only
ContentSecurityPolicy: !Sub >-
default-src 'none';
img-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
object-src 'none';
font-src 'self' https://fonts.gstatic.com;
manifest-src 'self';
connect-src 'self' https://dovshfcety3as.cloudfront.net https://${AuthDomain}.auth.${AWS::Region}.amazoncognito.com https://cognito-idp.${AWS::Region}.amazonaws.com;
frame-ancestors 'self';
base-uri 'self';
form-action 'self'

Integrating Cognito

When it comes to integrating Cognito, AWS recommends using Amplify. Like in part 1, I opted out of Amplify since it introduces extra overhead and I ended up learning more from using the APIs directly. You can also use OAuth libraries to help with these standard protocols, but I also opted out of this option. All the user pool endpoints can be found in the docs. We need to perform the following steps to sign in:

  • Make a GET request to /oauth2/authorize in the user pool domain. The URL will follow this format: https://[MY-DOMAIN-NAME].auth.[REGION].amazoncognito.com. This will open the hosted UI to allow the user to enter their credentials. The following query parameters need to be passed:
    - client_id: The client ID can be found by viewing the user pool client in the AWS console.
    - response_type: Set to code to perform an Authorization Flow as specified earlier.
    - scope: Use the same scopes specified in the CloudFormation template, separated by spaces (encoded with %20). For example, email%20openid%20phone%20profile%20aws.cognito.signin.user.admin
    - redirect_uri: This should match one of the callback URLs defined in CloudFormation, depending on your environment. Once the user authenticates, the browser will automatically redirect back to this URL with the appropriate query parameters.
    - state: This is recommended to protect against CSRF (cross-site request forgery) attacks. It should be a random string generated by the client. Once the user authenticates, the client should verify that the state returned matches the one sent.
    - code_challenge: This and code_challenge_method are required for PKCE. First, the client generates a random code. Then it should pass this code hashed using the code challenge method below and URL-encoded in base64. (Unlike regular base64, “+” is replaced with “-”, “/” is replaced with “_”, and extra “=” characters at the end are removed.)
    - code_challenge_method: Set to S256 (for SHA-256).
    - nonce: This is recommended to protect against replay attacks. It should be a random string generated by the client. Once the user obtains an ID token, the client should verify that the nonce appears as a claim in the token.
    - This will return a code and state in the query parameters of the redirect URI.
  • Make a POST request to /oauth2/token in the same domain to exchange the code for JWTs. The following x-www-form-urlencoded parameters need to be passed:
    - grant_type: Set to authorization_code.
    - client_id: This is the client ID from earlier.
    - code: This is the code from the previous response (not the one generated for the code challenge).
    - redirect_uri: This is the same callback URL from earlier.
    - code_verifier: This is the original, unhashed PKCE code generated from the previous step.
    - The response will contain the access token, ID token, refresh token, token type (Bearer), and the number of seconds until the access & ID tokens expire.
  • If the tokens need to be refreshed, make the same POST call to /oauth2/token with the following parameters:
    - grant_type: Set to refresh_token.
    - client_id: This is the client ID from earlier.
    - refresh_token: Pass the refresh token.
    - The response will be similar to the one above, except without the refresh token.
  • To sign out, call POST /oauth2/revoke with the following x-www-form-urlencoded parameters:
    - token: This is the refresh token.
    - client_id: Pass the client ID.
    - If successful, a 200 response is returned with an empty body.

At this point, the user is signed in. As long as the refresh token is available, you can keep refreshing/generating new access/ID tokens to control access to resources. It’s recommended to store the refresh token in a secure location. On iOS, this would be the Keychain. On Android, this would be the Keystore. And on the web, this would be an HttpOnly cookie. However, this can only be set by a server, which Cognito doesn’t do by default without a Lambda trigger. Alternatively, you can delegate the refresh token to a separate server so it’s completely isolated from the UI.

You can inspect the ID token to view more details about the user. The profile scope will allow you to view all the user’s attributes, including custom ones, that have read access enabled. Alternatively, you can make a GET call to /oauth2/userInfo with the access token in the Authorization header to view the user’s profile.

Besides the domain URL, you can also make API calls to the Cognito IdP. Its URL will be of the format:
https://cognito-idp.[REGION].amazonaws.com/[USER_POOL_ID].

  • GET /.well-known/openid-configuration will return information about the IdP, including settings from the CloudFormation template.
  • GET /.well-known/jwks.json will return a list of JWKs (JSON Web Keys) used to sign the tokens. This is needed to validate the tokens come from Cognito. Note that Cognito uses 2 keys: one for signing the ID token and one for signing the access token.

One last thing worth bringing up is that there should be a way to let users delete their accounts. This is a requirement for mobile apps and several countries enforce the same for web apps. This will help ensure the user’s privacy is respected. However, this API call is different from the other ones we discussed. We need to make an API call to Cognito itself using the DeleteUser API. Unlike most of AWS’s APIs, this action doesn’t require IAM credentials, just the access token. So, front-end apps can safely import the Cognito SDK to help make this call. It’s possible to call this API without the SDK, but it isn’t well-documented. You need to make a POST call to the IdP URL mentioned earlier. In the headers, set Content-Type to application/x-amz-json-1.1 and set x-amz-target to AWSCognitoIdentityProviderService.DeleteUser. The body is a JSON object with the access token set to the AccessToken key. Hopefully, you remembered to add the aws.cognito.signin.user.admin scope, because that’s also required to make any of these Cognito API calls.

I hope this sheds some light into how Cognito works. It’s not perfect, but it’s usable and affordable for simple use cases like these. You can check out the GitHub repo at https://github.com/Abhiek187/aws-shop if you want to browse the full source code. Thanks for reading!

P.S. I added a new part to this series all about collecting metrics using Pinpoint. Please check it out here.

--

--