How Cognito User Pools work

Managing user logins in AWS

Author's image
Tamás Sallai
8 mins
Photo by Jon Tyson on Unsplash

Cognito User Pools is a user directory where the users of an application can authenticate before they can access protected resources. In practice, that means Cognito stores the users, issues tokens on login, and then the backend when it receives a request reads who the user is. No more hashing passwords or implementing MFA.

In practice, it is the AWS-native answer to this question:

I want to add user login to my application, what's the easiest way to do that?

Cognito is the "easiest" but not necessarily "easy". It has a lot of obscure configuration and rough edges. But compared to implementing a user store with all the complexities such as storing passwords, protecting the endpoints, or implementing all the necessary MFA options, it's still easy.

In this article, we'll look into how Cognito User Pool login works, and for that we'll start by reviewing the 2 main ways of authentication: session-based and token-based.

Session-based login

If we go back 2 decades, session-based login was everywhere. Here, the client goes to the login endpoint that is managed by the backend, present the credentials, then gets a session ID. In the background, the backend writes to the sessions table a record with the session ID. Then this ID is stored in a cookie, such as JSESSIONID or PHPSESSID.

When the client sends a request to the backend, it automatically includes this cookie. To know the calling user, the backend reads the cookie value then fetches the corresponding row from the database. This row then contains who the user is, and any extra data associated with the session.

For example, Liferay portal uses a session cookie (JSESSIONID) to keep track of who the logged-in user is. When the user logs in, the server internally sets the session to the logged-in user. So every time when the cookie is sent, the page shows that the user is logged-in:

A screenshot showing that when the JSESSIONID cookie is sent the user is logged in

When the cookie is not sent, the user is logged out:

A screenshot showing that when the JSESSIONID cookie is not sent the user is not logged in

Notice that security here depends on the protection of the session ID as with that an attacker can send requests to the backend in the name of the user. Because of this, cookie security is important in this case. Historically, CSRF attacks were a big concern but with the SameSite attribute it is largely mitigated (though it's a bit more nuanced topic, see here and here but using the __Host prefix, setting SameSite=Lax, and not allowing anything fishy via GET should be enough).

Analysis

The main advantage of this approach is that it is entirely transparent to the client. Upon login, the backend sets the session cookie, returns the Set-Cookie headers that the browser automatically stores, and then all future requests will be authenticated with the session ID.

Other than that, the content of the session is hidden from the user. Since it is stored server-side in a database, the user has no knowledge about what is stored there.

Finally, log-out is instantaneous: the backend deletes one row from the database and from that point all requests with that session ID are invalid.

The main disadvantage is that it requires the authentication endpoint to be on the same domain as the backend. This is because the Set-Cookie can only set cookies for the current domain.

And finally, this is a stateful way to handle logins as sessions are stored in a database. That means every request now includes a database read and that adds a couple of milliseconds (depending on the database) extra latency.

Token-based login

With a token-based login the authentication is moved to a separate component. When the client does the login it goes to this separate auth service, inputs the username, password, MFA, and anything else that is needed for login, gets a token, and then uses that token to send requests to the backend.

The token is usually a JWT that is signed and time-limited and the backend is configured to trust the keys used for signing. For every request, it can then validate the token's signature, its expiration time, some additional fields, and then extracts the information about the user.

For example, AppSync when configured with Cognito (or OpenID Connect) works this way.

In one example scenario, the user is redirected to a login endpoint where a form is presented:

A screenshot showing that the webapp redirects to a login endpoint

Then the login endpoint redirects back with a token that the webapp can exchange for an access token:

A screenshot showing that the webapp gets the access token

Finally, when the webapp makes any authenticated request to the backend it attaches the access token value in the Authorization header, and that's how the backend knows who the user is:

A screenshot showing that the webapp sends the token value to the backend in the Authorization header

Analysis

In this setup, the authentication and the user management is moved to a separate component, the backend does not need to worry about storing passwords or sending out forgot-password emails. By the time the request reaches the backend the user is already registered and logged in, the only thing the backend needs to do is to check the token.

Also, this is a mostly stateless solution*. While the backend needs to fetch the trusted keys from the authentication service, it has to do it every once in a while but not for every request. As long as the token is not expired, checking it is done offline.

The main disadvantage is that token storage is problematic. Since it can't use cookies due to the different domains, the token value has to be accessible to the frontend JS code. This is a problem: any security problem potentially leaks the token value allowing an attacker to impersonate the victim user. This can be mitigated somewhat with a CSP (Content Security Policy) but that's complicated to implement.

Further, log-out is problematic. Since a token is valid until it's expired and there is no revocation mechanism in place, if a user logs out the tokens isuued before are still valid. This is somewhat mitigated by having an access token-refresh token pair where the access token is short-lived (typically 15 minutes) and the refresh token can be used to get new access tokens. Since the refresh token is used with the authentication service, it can implement a revocation that can serve as the basis for logout: when the user logs out, the refresh token is invalidated, which means that the access token is only usable until its (short) expiration.

Cognito

To make getting started with Cognito harder, it's 2 distinct component under the same umbrella: Identity Pools and User Pools. The former is used to give AWS credentials that allow clients to access AWS directly, which is almost always not what you want. User Pools store users, manage authentication, and generates tokens for backend access.

In this article we're only discussing User Pools and not Identity Pools.

User pools implement the token-based authentication: users log in into Cognito then it issues tokens. These tokens then used when sending requests to the backend to identify the user.

Login flows

Cognito supports two ways to handle sign-ins: OAuth and a custom challenge-response protocol.

OAuth

The former is activated with the Hosted UI and that is a redirect-based approach: the application redirects the user to the endpoint of the hosted UI that takes over the login process. When the login is successful it redirects the user back to the application. Then the application can retrieve the tokens via a TOKEN endpoint and use them to access the backend.

Cognito-based login

The disadvantage of the Hosted UI is that the login is separated from the application: the user is redirected to an entirely separate page to log in and leaves the page in the process to come back after authentication.

The alternative is an HTTP-based login process that is specific to Cognito. During login, Cognito sends a series of challenges to the user such as asking for a password, MFA token, as well as setup processes such as password reset or MFA setup and it's possible to extend the flow with custom steps. When Cognito is happy with the responses, it returns the tokens to the user.

The application needs to support all challenge types and respond with the required data.

Refresh token

Cognito returns 2 tokens: an access token and a refresh token. The former is used with the backend to show who the logged in user is, while the latter is used to get new access tokens.

The reason for this separation is expiration time. Access tokens are short-lived because that is the mechanism for sign-out. Then refresh tokens are longer-lived (configurable) so that users won't need to enter their passwords too many times.

June 11, 2024
In this article