… but if you want to know your users in AWS, it is now easier than ever, thanks to 2 “recently” announced ALB features;

First one allows you to add authentication for existing application simply by placing an ALB in front of your service/servers and adding an authentication step in listener configuration. Second one makes it possible to replace those servers with Lambda functions and go serverless!

Together these make it easy to build a demo application that will authenticate users from Cognito as a single Cloudformation template. For simplicity I’m assuming there is already a 1) Cognito Userpool, 2) Route53 Zone and 3) matching SSL certificate in ACM available.

SAM Template will build and configure ALB, create lambda function backend and configure Cognito userpool using CFN custom resource (another Lambda function). Custom resource is required because Cloudformation doesn’t support all features of Cognito configuration :-(

Cloudformation Template

I’m not going to analyze the whole template line by line, but just highlight the most interesting details that may not be 100% obvious when doing this for the first time.

AWS::ElasticLoadBalancingV2::Listener is where most of authentication config is. This is rather self-explanatory and as I don’t have any path based routing to different backend functions I only have DefaultActions -block.

  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    DependsOn: CognitoAppClientSettings
    Properties: 
      DefaultActions: 
        - Type: authenticate-cognito
          Order: 1
          AuthenticateCognitoConfig: 
            OnUnauthenticatedRequest: authenticate
            Scope: openid
            SessionCookieName: AWSELBAuthSessionCookie
            SessionTimeout: 60
            UserPoolArn: !Ref CognitoUserpoolArn
            UserPoolDomain: !Ref CognitoUserpoolDomain
            UserPoolClientId: !Ref CognitoAppClient
        - Type: forward
          Order: 2
          TargetGroupArn: !Ref LambdaTargetGroup
      LoadBalancerArn: !Ref ALB
      Port: 443
      Protocol: HTTPS
      Certificates: 
        - CertificateArn: !Ref SSLCert

AWS::ElasticLoadBalancingV2::TargetGroup only defines the function that is executed to process HTTP request. However, to allow ALB to execute the function you must also define AWS::Lambda::Permission and grant access to elasticloadbalancing.amazonaws.com. Permission must be granted before target group is created, that’s why the explicit dependency on LambdaExecPermission.

  LambdaTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    DependsOn: LambdaExecPermission
    Properties: 
      HealthCheckEnabled: False
      Name: !Ref AWS::StackName
      TargetType: lambda
      Targets: 
        - Id: !GetAtt JWTverifier.Arn

  LambdaExecPermission:
    Type: AWS::Lambda::Permission
    Properties: 
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt JWTverifier.Arn
      Principal: elasticloadbalancing.amazonaws.com

AWS::Cognito::UserPoolClient has very limited Cloudformation support. All you can do is create a client for given userpool and define what user attributes will be delivered.

  CognitoAppClient:
    Type: AWS::Cognito::UserPoolClient
    Properties: 
      ClientName: !Sub '${AWS::StackName} ALB'
      ExplicitAuthFlows: 
        - USER_PASSWORD_AUTH
      GenerateSecret: True
      RefreshTokenValidity: 1
      UserPoolId: !Select [1, !Split [ 'userpool/', !Ref CognitoUserpoolArn ]]
      ReadAttributes: 
        - email
        - email_verified

Rest of UserPoolClient configuration is handled by calling updateUserPoolClient AWS API via custom resource. I’m using generic Lambda function which will call AWS API defined in Action with Parameters.

  CognitoAppClientSettings:
    Type: Custom::CognitoUserPoolClientSettings
    Properties:
      ServiceToken: !GetAtt CustomResource.Arn
      Service: CognitoIdentityServiceProvider
      Create:
        Action: updateUserPoolClient
        Parameters:
          UserPoolId: !Select [1, !Split [ 'userpool/', !Ref CognitoUserpoolArn ]]
          ClientId: !Ref CognitoAppClient
          AllowedOAuthFlows: [ code ]
          AllowedOAuthScopes: [ openid ]
          SupportedIdentityProviders: [ COGNITO ]
          AllowedOAuthFlowsUserPoolClient: true
          CallbackURLs:
            - !Sub https://${AWS::StackName}.${R53Zone}/oauth2/idpresponse

UPDATE 9/10/2019 After AWS::Cognito::UserPoolClient update it is no longer necessary to modify client settings with custom resource but all parameters are supported by standard cloudformation. Here is the updated version of template.

See also Authenticate Users Using an Application Load Balancer for the summary of all things included in ALB authentication configuration.

Lambda Backend

My demo function does 2 things; it validates JWT token from ALB and returns ALB compatible respose. ALB and function must on the same AWS account, but not necessary in the same region. Function doesn’t have to be in VPC to work with ALB, but that might be needed if the code does access data stores in VPC, for example.

import requests
import base64
import json
import jwt

def lambda_handler(event, context):

    # Step 1: Get the key id from JWT headers (the kid field)
    encoded_jwt = event['headers']['x-amzn-oidc-data']
    jwt_fields = encoded_jwt.split('.')
            
    jwt_headers = jwt.get_unverified_header(encoded_jwt)
    jwt_sig = jwt_fields[2]

    # NOTE: Payload is in base64 clear-text, but you should use jwt.decode() to verify the signature!  
    # jwt_payload = json.loads(base64.b64decode(jwt_fields[1]))
    
    # Step 2: Get the public key from regional endpoint
    aws_region = context.invoked_function_arn.split(':')[3]
    url = 'https://public-keys.auth.elb.' + aws_region + '.amazonaws.com/' + jwt_headers['kid']
    pub_key = requests.get(url).text

    # Step 3: Get the payload
    jwt_payload = jwt.decode(encoded_jwt, pub_key, algorithms=[jwt_headers['alg']])
    
    response = {
        "statusCode": 200,
        "statusDescription": "200 OK",
        "isBase64Encoded": False,
        "headers": {
            "Content-Type": "text/html; charset=utf-8"
        }
    }
    
    response['body'] = ( "<html><head><title>Hello JWT</title></head><body><pre>"
        + 'Encoded JWT:\n' + encoded_jwt + '\n\n'
        + 'JWT Headers:\n' + json.dumps(jwt_headers, indent=2, sort_keys=True) + '\n\n'
        + 'JWT Payload:\n' + json.dumps(jwt_payload, indent=2, sort_keys=True) + '\n\n'
        + 'JWT Signature:\n' + jwt_sig + '\n'
        + "</pre></body></html>" )

    return response

Lambda Layer

Lambda Python environment has many frequently used packages installed but I needed PyJWT and it’s dependencies to verify JWT token from ALB. I choosed to put missing python packages into Lambda layer and not to break the illusion of complete demo delivered in single template. You’re welcome to use my layer published at

arn:aws:lambda:eu-west-1:017001121740:layer:pyjwt:1

or build you own with buildlayer.sh. Note that prebuild layer is available only at eu-west-1. If you plan to build the stack somewhere else you must provide your own layer as function can’t use layer from remote regions.

NOTE: For this demo you need PyJWT. I found it very easy to mix it with JTW. These modules don’t really work together well and are not compatible. Here is the stackoverflow thread about the subject.

It’s Demo Time

After you have created a stack from template you will find URL for ALB from stack outputs.

This will take you first to Congito login screen and then, after sucessful login, to the loadbalancer backend which in this demo shows info available from JWT token.

Beautiful thing here is, I didn’t have to spend any time in building/hosting authentication service, including signup, signin, email verification, password recovery etc. but all is taken care by Cognito. Thing that wasn’t shonw in this demo was customzing Cognito UI. For real application you would want Cognito UI match with the application style.