Using the Cognito Hosted UI with an Application Load Balancer

Originally published: 2020-08-18

Last updated: 2020-08-18

AWS introduced the Application and Network load balancers in the summer of 2016, and I think that most of the organizations that already used the newly-christened “Classic” load balancer let out a big yawn. At the time, the primary benefit of the ALB was that it could do path-based routing to multiple hosts. But this was a solved problem: either the applications were monoliths that did routing internally, or were deployed with nginx or haproxy as a front-end. Plus, the ALB was a “layer 7” load balancer: you couldn't use it to direct arbitrary TCP traffic (meaning no tricks like using the ELB as a bastion host).

As with many AWS products, the ALB continually gained new features, with a flurry happening in the late spring and summer of 2018. Integration with the Cognito hosted UI was one of the most interesting to me: I'd spent time working with Cognito shortly after it came out in 2016, and came away less than impressed. At the time, Cognito was clearly targeted at client-side integration; the APIs that Could be used by a server were intended for “administrative” actions. By 2017, when the Hosted UI was introduced, I'd moved on to other things.

But, as I've said before, Cognito does offer a compelling feature set, and I recently had reason to use the hosted UI with a web-app. There are still some bits that aren't documented that well, and some quirks of the implementation that might not be immediately obvious, which is why I'm writing this article. That said, if you can use it, I wholeheartedly recommend that you do.

Many of the examples in this page use CloudFormation resource definitions; I believe that they're a better form of documentation than screenshots or command-line operations. They're from this template, which deploys a simple web-app using a Lambda to respond to API requests.

Configuring the Cognito Hosted UI

If you're not familiar with the hosted UI, here's a picture of it: Cognito hosted UI, waiting for input

As you can see, it's a simple username/password field. This image uses default styling; you can change some but not all elements to customize it for your application. If you look closely, you can see that it's hosted in the domain amazoncognito.com; you can change that to be a hostname within your own DNS domain, although there are restrictions.

What's nice about the hosted UI is that it supports all of the Cognito user flows: logging in, of course, but also signing up for a new account (if enabled), confirming that new account, and changing a forgotten password. While these things are not necessarily difficult to build yourself, using the hosted UI saves you time that can be spent on other parts of your web-app.

There are two steps to enabling the hosted UI. First, you must pick a domain name, which is surprisingly tricky. If you already have a registred domain, you will probably want to use it. However, there are several prerequisites to doing so, including a requirement that the domain root must be an “A” (address) record. In other words, if you want to use auth.example.com for Cognito, then example.com must be point to a physical server. If you have a dedicated domain for your application this may not be an issue; if you're working inside a corporate domain structure, it probably will be.

The alternative is to pick a name within the amazoncognito.com domain. Similar to S3 bucket names, this namespace is shared between all AWS accounts; unlike S3, names must only be unique within the desired region. You almost certainly won't be able to use “example” or “auth”. You also aren't allowed to use anything that contains “cognito”: it's a reserved hostname in the AWS world. I recommend using a reverse-DNS name like “com-mycompany-myapp”; you can use up to 63 alpha-numeric-hyphen characters.

Here's an example CloudFormation configuration, using the amazoncognito.com domain with a hostname passed as a stack parameter.

CognitoUserPoolDomain:
  Type:                               "AWS::Cognito::UserPoolDomain"
  Properties: 
    UserPoolId:                       !Ref CognitoUserPool
    Domain:                           !Ref AuthHostname

The next step is configuring the Cognito application client. This is described in general terms in the Cognito documentation, but you also need to read the load balancer documentation for specific configuration elements (particularly, required scopes and callback URLs). Here is the configuration I'm using:

CognitoUserPoolClient:
  Type:                               "AWS::Cognito::UserPoolClient"
  Properties:
    UserPoolId:                       !Ref CognitoUserPool
    ClientName:                       "default"
    SupportedIdentityProviders:       [ "COGNITO" ]
    AllowedOAuthFlowsUserPoolClient:  true
    AllowedOAuthScopes:               [ "openid", "email" ]
    AllowedOAuthFlows:                [ "code" ]
    CallbackURLs:                     [ !Sub "https://${Hostname}.${DNSDomain}/oauth2/idpresponse" ]
    GenerateSecret:                   true
    RefreshTokenValidity:             7
There are a few things to call out in this configuration:

Configuring the Load Balancer

Configuring the load balancer is straightforward: in any rules that should be authenticated, you add an “authenticate-cognito” action before whatever action the rule would otherwise take. In this example, I want to protect the “/api” path, and deny any requests from users that have not already been authenticated, so set the OnUnauthenticatedRequest property to “deny”.

LoadBalancerAPIDispatchRule:
  Type:                               "AWS::ElasticLoadBalancingV2::ListenerRule"
  Properties: 
    ListenerArn:                      !Ref LoadBalancerHTTPSListener
    Priority:                         20
    Conditions: 
      - Field:                        "path-pattern"
        Values:
          -                           "/api/*"
    Actions: 
      - Type:                         "authenticate-cognito"
        Order:                        100
        AuthenticateCognitoConfig:
          UserPoolArn:                !GetAtt CognitoUserPool.Arn
          UserPoolClientId:           !Ref CognitoUserPoolClient
          UserPoolDomain:             !Ref CognitoUserPoolDomain
          OnUnauthenticatedRequest:   "deny"
      - Type:                         "forward"
        Order:                        200
        TargetGroupArn:               !Ref APITargetGroup

That last bit — have not already been authenticated — is key: you also need a route with an OnUnauthenticatedRequest value of “authenticate”, which will initiate the series of redirects shown in the next section. This is normally applied to the “home” page for your application.

LoadBalancerHomeDispatchRule:
  Type:                               "AWS::ElasticLoadBalancingV2::ListenerRule"
  Properties: 
    ListenerArn:                      !Ref LoadBalancerHTTPSListener
    Priority:                         10
    Conditions: 
      - Field:                        "path-pattern"
        Values:
          -                           "/"
    Actions: 
      - Type:                         "authenticate-cognito"
        Order:                        100
        AuthenticateCognitoConfig:
          UserPoolArn:                !GetAtt CognitoUserPool.Arn
          UserPoolClientId:           !Ref CognitoUserPoolClient
          UserPoolDomain:             !Ref CognitoUserPoolDomain
          OnUnauthenticatedRequest:   "authenticate"
      - Type:                         "forward"
        Order:                        200
        TargetGroupArn:               !Ref HomeTargetGroup

That doesn't look terribly tricky, does it? But it assumes a “micro service” architecture, in which separate target groups handle the API and home routes. If you have a monolithic architecture, you typically have a single target group that handles routing internally; you'll need to duplicate some of that routing in the ALB to enable Cognito. And if you have a “single page application” architecture, you might be loading the home page as static content; you'll need to move that content into the load balancer configuration (which I show below).

How requests are handled once Cognito is enabled

While the AWS documentation describes the authentication flows, I think some diagrams are helpful.

I'll start with the login flow: this happens when an unauthenticated user tries to go to a page where the listener rule's OnUnauthenticatedRequest property is “authenticate”:

ALB/Cognito login flow

As you can see, there's a lot of back-and forth. The first thing that happens is that the load balancer discovers that the user hasn't been authenticated, which it does by looking for a cookie (normally named “AWSELBAuthSessionCookie”, but you can configure this if desired). For an unauthenticated user, that cookie is empty or has a value that indicates authentication has expired.

When that's the case, the load balancer responds to this initial request by redirecting the client to Cognito's authorization endpoint, /oauth2/authorize. This endpoint is part of the OAuth 2.0 specification; it is responsible for verifying the user's identity and returning an authorization code to the requester. I don't show the parameters passed to this request, but there are several of them, including the Cognito client ID and (importantly) the URL to which the user should be redirected after successful authentication.

Since this is an unauthenticated user, Cognito redirects to its own /login endpoint, which returns the hosted UI page content. This page requires some supporting static content, loaded from an AWS-managed CloudFront distribution; I don't show those requests here.

The hosted UI page supports several interaction flows. For this diagram I'm showing the simplest, in which the user enters their (correct) credentials and the form does a POST back to the /login endpoint.

Assuming successful login, the hosted UI responds with a redirect to the load balancer's /oauth2/idpresponse endpoint, providing authentication information. This endpoint is handled internally by the load balancer; you don't need to add an explicit rule for it. Behind the scenes, the load balancer stores the authentication information in the session cookie (if you want to know more about this step, take a look at the OAuth2 page linked above).

Finally, he client is redirected back to the page that it originally requested. At this point, we can move on to the authorization flow.

But first, some terminology. “Authentication” refers to the process in which a user provides credentials (username and password) to an “identity server.” In the ALB/Cognito integration, the hosted UI is the identity server, and it returns a token that says that the user has passed that step successfully. “Authorization” is a separate step in which the application validates that the token is still valid and allowed to access the service.

When using the ALB/Cognito integration, authorization happens in two places. The first of these is the load balancer:

ALB/Cognito authorization flow

This is the general flow for any request that uses a Cognito-authorized rule. When the client makes a request, the load balancer does not immediately pass that request on to the application. Instead, it first contacts Cognito to authorize the request, passing the authentication data that it saved in the session cookie. This happens behind the scenes; neither your application nor the user are involved.

If the user is authorized, then the load balancer adds three headers to the request and passes it on to your application.

If the user is no longer authorized (either the authorization has expired or the user was explicitly logged out), then what happens next depends on the listener rule's OnUnauthenticatedRequest property:

Validating authorization in your application

As I said above, the load balancer adds three headers to an authorized request:

x-amzn-oidc-accesstoken A JWT token that contains information about the user's authorization status: the Cognito user pool that authorized the user, the user's identity within that pool, when the user was authorized, and when the authorization expires. This should be your primary way to authorize a request.
x-amzn-oidc-data A JWT token that contains additional user attributes managed by Cognito. As I've configured the user pool above, this is limited to the user's email address, but you can configure additional attributes.
x-amzn-oidc-identity The unique identifier for the user within the Cognito user pool. For my example pool this is a UUID.

If you are not familiar with JWT, the short description is that it's a way to encode information (“claims”) about a user. A token consists of three parts: a header, which identifies the signing algorithm and signature key; the payload, which contains the claims; and the binary signature. These three pieces are Base64-encoded and combined into a single string.

Using the decoder at jwt.io, here are the header and claims for two sample tokens (I've omitted the signature, which is just a series of bytes).

x-amzn-oidc-accesstoken header:
{
  "kid": "O69l+/7w0rDheUK1BxhKYd15BZm5ZnUeYxroPtzWvss=",
  "alg": "RS256"
}
x-amzn-oidc-accesstoken payload:
{
  "sub": "256e9913-c1ab-461b-b3f2-58ab40bd9df6",
  "event_id": "6580e854-57d7-4e48-82c6-ca3f704d3a7f",
  "token_use": "access",
  "scope": "openid",
  "auth_time": 1597144816,
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_rc95776ud",
  "exp": 1597148416,
  "iat": 1597144816,
  "version": 2,
  "jti": "5480aca7-012a-4e8a-a417-2655a41f7756",
  "client_id": "186u6bp3uo400cpromr7nji8h0",
  "username": "256e9913-c1ab-461b-b3f2-58ab40bd9df6"
}
x-amzn-oidc-data header:
{
  "typ": "JWT",
  "kid": "27ffb1cc-efd3-4826-a5b2-961b4eb888dc",
  "alg": "ES256",
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_rc95776ud",
  "client": "186u6bp3uo400cpromr7nji8h0",
  "signer": "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/Example/4c2620b361050145",
  "exp": 1597144938
}
x-amzn-oidc-data payload:
{
  "sub": "256e9913-c1ab-461b-b3f2-58ab40bd9df6",
  "email_verified": "true",
  "email": "kdgregory@example.com",
  "username": "256e9913-c1ab-461b-b3f2-58ab40bd9df6",
  "exp": 1597144938,
  "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_rc95776ud"
}

Of the various fields, here are the ones that you're most likely to care about:

alg Algorthm: the algorithm used to produce the signature.
kid Key Identifier: a unique identifier for the key used to sign the token.
iss Issuer: the service that issued the JWT. For Cognito, this takes the form of a URL: “https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID”.
client_id Client ID: identifies the Cognito user pool client (you can have multiple clients for a single pool, although the example only creates one).
sub Subject: the user's unique identifier within the Cognito user pool. Note that there's also a “username” claim, and the way that I've configured the pool makes them the same; I believe that if you allow users to pick their own usernames they will be different.
email User email address. This is part of the user token because I enabled it in AllowedOAuthScopes.
iat Issued At: the timestamp (seconds since epoch) when the JWT was created.
exp Expiration: the timestamp (seconds since epoch) after which the token should be considered invalid (and the user no longer authorized).

To validate a JWT, you must check the following:

Making validation more painful than it should be, these tokens are signed by different signers, using different algorithms, with different key formats.

The access token is easy to decode: it uses the “rs256” algorithm, and the public key is available as a JWKS (JSON Web Key Set) from the URL https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID/.well-known/jwks.json (substituting REGION and USER_POOL_ID with the values for your Cognito user pool). Using the java-jwt and jwks-rsa-java from Auth0, here is how you would decode and validate the access token:

private boolean validateAccessToken(String awsRegion, String cognitoPoolId, String accessToken)
throws Exception
{
    DecodedJWT jwt = JWT.decode(accessToken);

    String cognitoIssuer = "https://cognito-idp." + awsRegion + ".amazonaws.com/" + cognitoPoolId;
    
    JwkProvider provider = new UrlJwkProvider(cognitoIssuer);
    Jwk jwk = provider.get(jwt.getKeyId());

    RSAPublicKey pubkey = (RSAPublicKey)jwk.getPublicKey();
    Algorithm algorithm = Algorithm.RSA256(pubkey, null);

    JWTVerifier verifier = JWT.require(algorithm)
                           .withIssuer(cognitoIssuer)
                           .build();

    try
    {
        verifier.verify(accessToken);
        return true;
    }
    catch (JWTVerificationException ex)
    {
        logger.warn("access token failed validation for {}: {}", jwt.getSubject(), ex.getMessage());
        return false;
    }
}

In a real-world application I would retrieve the JWK once and cache it, rather than making an HTTP call for every key (some providers rotate their keys, but according to the documentation, Cognito doesn't). I would also return one or more of the claims — typically “sub” — rather than a boolean.

Validating the user token isn't quite so easy. In fact, it is sufficiently difficult that in a real-world application I would extract the “sub” claim from the access token and then contact Cognito directly to retrieve the user information (caching the result so that I'm not hitting Cognito for every request).

The problem is that this token is signed using the “es256” algorithm. And while that's a perfectly valid algorithm, it's clearly not the first choice for JWT providers. The Auth0 JWKS library that I used above, for example, only supports RSA keys.

Making the challenge greater, the load balancer team also decided not to use the JWKS framework. Instead, they expose an OpenSSL public key file at the URL https://public-keys.auth.elb.REGION.amazonaws.com/KEY_ID, where KEY_ID is the “kid” header in the JWT (so the JWT essentially signs itself). Here's an example:

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELvTktrdSseaOZQxiSVgK68u91Rt8
eY/CUJMUTYEyyrbEM1jHuk764ksXSoIS0We2BUhXNpvF/6YMp0qWthwhuA==
-----END PUBLIC KEY-----

That's a Base64-encoded version of the 91-byte key specification. Here's the code in Java to download that key and translate it:

private static ECPublicKey readPublicKey(String url)
throws Exception
{
    StringBuilder keyText = new StringBuilder(1024);
    URLConnection cxt = new URL(url).openConnection();
    try (InputStream in0 = cxt.getInputStream())
    {
        BufferedReader in = new BufferedReader(new InputStreamReader(in0, StandardCharsets.UTF_8));
        String line;
        while ((line = in.readLine()) != null)
        {
            if (! line.contains("PUBLIC KEY"))
                keyText.append(line);
        }
    }

    byte[] keyData = Base64.getDecoder().decode(keyText.toString());
    X509EncodedKeySpec keyspec = new X509EncodedKeySpec(keyData);

    KeyFactory factory = KeyFactory.getInstance("EC");
    return (ECPublicKey)factory.generatePublic(keyspec);
}

Once you've downloaded and converted the public key, verifying the token using it looks a lot like the code used to verify access tokens (although here I skip issuer validation):

public boolean validateUserToken(String keyId, String userToken)
throws Exception
{
    ECPublicKey pubkey = readPublicKey(ALB_PUBLIC_KEY_URL_BASE + keyId);
    Algorithm algorithm = Algorithm.ECDSA256(pubkey, null);

    JWTVerifier verifier = JWT.require(algorithm).build();
    try
    {
        verifier.verify(userToken);
        return true;
    }
    catch (Exception ex)
    {
        logger.warn("user token failed validation using key {}: {}", keyId, ex.getMessage());
        return false;
    }
}

As I said, I'd rather go directly to Cognito to retrieve these claims. Here's an example that retrieves the user attributes based on the “sub” claim (with a simple cache to improve performance for returning users):

private String retrieveEmailAddress(String cognitoUserId)
{
    String emailAddress = usernameLookup.get(cognitoUserId);
    if (emailAddress != null)
        return emailAddress;

    logger.debug("retrieving email address for {}", cognitoUserId);
    AdminGetUserRequest request = new AdminGetUserRequest()
                                  .withUserPoolId(cognitoPoolId)
                                  .withUsername(cognitoUserId);
    AdminGetUserResult response = cognitoClient.adminGetUser(request);
    for (AttributeType attr : response.getUserAttributes())
    {
        if ("email".equals(attr.getName()))
        {
            emailAddress = attr.getValue();
            usernameLookup.put(cognitoUserId, emailAddress);
            break;
        }
    }

    return emailAddress;
}

Single-page Applications

In the past decade, web applications have transitioned from individual pages generated by a server and using JavaScript only for interactivity, to “single-page” applications that retrieve data from the server and render their content locally. This type of web-app relies on static content (JavaScript, CSS, and possibly HTML templates) along with an API endpoint, which makes it very easy to deploy on AWS: static content lives on S3 and you can use API Gateway or an ALB to expose the API endpoint.

The problem with this approach, at least in the context of this article, is how to secure the application. An Application Load Balancer can not, at present, retrieve content from S3; that functionality is provided by CloudFront. But CloudFront does not support integration with the Cognito hosted UI; that functionality is only available in the ALB. Faced with this particular Venn diagram of functionality, most of the teams that I've known punt: they turn to client-side interaction with Cognito, using a framework like Amplify.

There is, however, one trick feature of the ALB that can solve this problem: fixed responses.

LoadBalancerHomeDispatchRule:
  Type:                               "AWS::ElasticLoadBalancingV2::ListenerRule"
  Properties: 
    ListenerArn:                      !Ref LoadBalancerHTTPSListener
    Priority:                         10
    Conditions: 
      - Field:                        "path-pattern"
        Values:
          -                           "/"
    Actions: 
      - Type:                         "authenticate-cognito"
        # ...
      - Type:                         "fixed-response"
        Order:                        200
        FixedResponseConfig:
          StatusCode:                 200
          ContentType:                "text/html"
          MessageBody:                |
                                      <!doctype html>
                                      <html lang="en">
                                      <head>
                                          <link rel='StyleSheet' href='...' type='text/css'>
                                          <title>Simple Page</title>
                                      </head>
                                      <body>
                                          <!-- body content omitted -->
                                      </body>
                                      <script src='...'></script>
                                      </html>

The “fixed-response” action allows you to return arbitrary content to the user. One common usage is for a listener's default action, which is invoked if no other rule applies: using a fixed response, you can return a 404 (Not Found) status.

But as long as you can fit your main page into 1024 bytes, there's no reason that you can't deliver it in a fixed response. And, as shown here, that lets you require authentication before the user can get to that content.

The remaining challenge is where to store the rest of your static content. You certainly wouldn't want to deliver your entire site as fixed responses, even if you weren't limited to 1024 bytes of content per rule. The simplest approach (at least once you get past CORS configuration) is to store the content on S3 and access it directly or via CloudFront.

What about CloudFront?

The AWS documentation provides three simple steps to enable Cognito authentication when your load balancer is sitting behind a CloudFront distribution. Unfortunately, they don't work.

Returning to the interaction diagram from a few sections ago, note that the first thing that happens to an unauthenticated user is that the ALB redirect that user to Cognito's authorization endpoint, /oauth2/authorize. As I noted, it will pass several parameters in that request, one of which is the URI that Cognito should redirect back to on successful authentication.

The problem is that the ALB provides its own hostname in that parameter, not the hostname of the CloudFront distribution. Which means that, after successful authentication, you will end up at a URL directly served by the ALB, bypassing CloudFront. What's more, if you try to redirect back to a CloudFront-exposed URL, your next request that goes through the load balancer will trigger re-authentication, because the necessary cookie doesn't have the correct domain. In this case you'll go through the flow as an already-authenticated user, but that still redirects you back to a load balancer-served URL.

Barring a change to the load balancer configuration that allows you to specify the redirect URL, this means that you can't use CloudFront in front of a Cognito-authenticated load balancer. You can still use CloudFront beside the load balancer, with its own hostname, to serve static content or non-authenticated services:

CloudFront serving static content, with Cognito-authenticated ALB

For More Information

If you'd like to dig into the various specifications and documentation for the underlying technologies:

If you'd like to see a full web-application that uses an ALB/Cognito integration, check out my LambdaPhoto example. As the name implies, it uses Lambdas written in Java to serve the web requests, which is interesting (this was originally written for a Java User Group talk), but probably not the best choice for a production web-app.

Copyright © Keith D Gregory, all rights reserved

This site does not intentionally use tracking cookies. Any cookies have been added by my hosting provider (InMotion Hosting), and I have no ability to remove them. I do, however, have access to the site's access logs with source IP addresses.