TutorialsAug 27, 202512 min read

Secure Streamlit app deployment with AWS Cognito, Streamlit, and CircleCI

Benito Martin

Data Scientist

As you develop internal tools or public-facing data applications, implementing authentication mechanisms becomes essential. Without authentication, you risk exposing sensitive information or allowing unauthorized access. Fortunately, integrating secure user access does not have to be complex. AWS Cognito provides a straightforward way to handle authentication, user management, and access control across multiple identity providers.

If you do not already have one, you will need to sign up for an AWS account. You will use AWS Cognito to manage user authentication.

AWS Cognito enables you to authenticate users securely, whether through traditional username and password or social identity providers like Google. When you pair this with Streamlit’s simplicity in building interactive, data-driven web applications and CircleCI’s powerful Continuous Integration and Continuous Deployment (CI/CD) capabilities, you create a robust, secure, and scalable deployment pipeline.

In this guide, you will build a secure Streamlit web application integrated with AWS Cognito for user authentication. You will also automate the testing and deployment workflow using CircleCI, so your application is continuously delivered with every push to your GitHub repository.

By the end of this tutorial, you will understand how to connect these technologies and streamline the process of building, securing, and deploying your Streamlit application.

Prerequisites

Before you begin, ensure that you have the following requirements in place:

  • AWS account: Sign up for an AWS account if you do not already have one. You will use AWS Cognito to manage user authentication.

  • AWS CLI installed and configured: Install the AWS Command Line Interface (CLI) and configure it with your AWS credentials. You can follow the AWS CLI setup guide.

  • Basic understanding of OAuth2 and authentication flows: A working knowledge of OAuth2 and general authentication flows will help you follow the logic behind integrating AWS Cognito with your application.

  • Familiarity with Streamlit: You should have basic familiarity with Streamlit and how to create interactive Python-based web applications.

  • GitHub and CircleCI accounts: You will need a GitHub account for version control and a CircleCI account to automate your CI/CD pipeline.

  • Google Cloud Platform (GCP) account: Set up a Google Cloud Platform so you can use Google as a social identity provider for AWS Cognito.

  • uv: Install uv a fast and modern tool for managing dependencies and Python virtual environments. You will use this to set up your project environment in a later step.

Once you have completed these steps, you will be ready to move forward with setting up the project and building your secure Streamlit application.

Setting up the project

Before you start building, you need to set up your project environment, install the required dependencies, and understand the role AWS Cognito will play in your application’s authentication flow.

Understand AWS Cognito’s role

AWS Cognito is a fully managed service that handles user sign-up, sign-in, and access control. In your Streamlit app, you will use Cognito’s User Pools and Hosted UI to authenticate users through either username/password or social login (e.g., Google). Cognito will issue OAuth2 tokens, which your application will use to validate and manage user sessions. Below is an architecture diagram of the application:

![App architecture] (//images.ctfassets.net/il1yandlcjgk/1TZvSrjfnJrYkO9bygYZsY/35b1e7f2fbdf8774ff70826a78534517/2025-04-23-architecture.png){: .zoomable }

Setting up the environment

First, clone the repository containing the project code:

git clone https://github.com/CIRCLECI-GWP/aws-cognito-circleci.git
cd aws-cognito-circleci

Then, install dependencies and set up the virtual environment by running these commands:

uv sync --all-groups
source .venv/bin/activate

These commands will:

  • Install the dependencies defined in pyproject.toml.
  • Automatically create a virtual environment (.venv).
  • Activates the virtual environment.

The sync command installs the main dependencies, and the --all-groups flag ensures that optional dependency groups are included as well.

Finally, create an .env file in the root directory of your repository and add these environment variables:

AWS_REGION=your-aws-region
POOL_NAME=StreamlitAppUserPool
CLIENT_NAME=StreamlitAppClient
CLIENT_ID=your-client-id
USER_POOL_ID=your-user-pool-id
DOMAIN_PREFIX=cog-app
REDIRECT_URI=http://localhost:8501
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

These variables are used to configure your application and integrate it with AWS Cognito.

Setting up the project structure

If you cloned the repository, you should already have the project structure in place. If you are setting it up manually, use the following structure as a guide:

aws-cognito-circleci/
├── .circleci/
│   └── config.yml
├── app/
│   ├── __init__.py
│   ├── main.py
├── src/
│   ├── __init__.py
│   ├── cognito_app.py
│   ├── hosted_domain.py
│   ├── google_idp.py
│   └── client_login_pages.py
├── tests/
│   ├── test_cognito_app.py
│   └── test_streamlit_app.py
├── .env.example
├── .gitignore
├── .pre-commit-config.yaml
├── LICENSE
├── Makefile
├── README.md
├── pyproject.toml
└── uv.lock

This structure promotes modularity and maintainability, making it easier to manage your application logic, authentication handlers, and CI/CD workflows.

Create an AWS Cognito user pool and client

To authenticate users in your Streamlit application, you need to create a Cognito User Pool, an App Client, and a Hosted UI domain.

Create the user pool and app client

Start by running the following command to execute the setup script:

uv run src/cognito_app.py
import json
import os

import boto3
import dotenv
from loguru import logger

# Load environment variables from .env file
dotenv.load_dotenv()

AWS_REGION = os.getenv("AWS_REGION", "eu-central-1")
POOL_NAME = os.getenv("POOL_NAME", "StreamlitAppUserPool")
CLIENT_NAME = os.getenv("CLIENT_NAME", "StreamlitAppClient")

# Set up loguru
logger.info("Starting Cognito setup script...")

# Create Cognito client
client = boto3.client(service_name="cognito-idp", region_name=AWS_REGION)

# Step 1: Check if User Pool exists and create if it doesn't
logger.info(f"Checking if user pool '{POOL_NAME}' already exists...")
existing_pool_id = None

paginator = client.get_paginator("list_user_pools")
for page in paginator.paginate(MaxResults=60, PaginationConfig={"MaxItems": 10}):  # type: ignore
    for pool in page["UserPools"]:
        if pool["Name"] == POOL_NAME:
            existing_pool_id = pool["Id"]
            logger.info(f"User pool '{POOL_NAME}' already exists with ID: {existing_pool_id}")
            break
    if existing_pool_id:
        break

if existing_pool_id:
    user_pool_id = existing_pool_id
else:
    logger.info(f"Creating new user pool '{POOL_NAME}'...")
    response_pool = client.create_user_pool(
        PoolName=POOL_NAME,
        Policies={
            "PasswordPolicy": {
                "MinimumLength": 8,
                "RequireUppercase": True,
                "RequireLowercase": True,
                "RequireNumbers": True,
                "RequireSymbols": False,
            }
        },
        AutoVerifiedAttributes=["email"],
        UsernameAttributes=["email"],
        MfaConfiguration="OFF",
    )
    user_pool_id = response_pool["UserPool"]["Id"]
    logger.success(f"User Pool created with ID: {user_pool_id}")

# Step 2: Check if App Client exists and create if it doesn't
logger.info(f"Checking if app client '{CLIENT_NAME}' already exists...")
existing_client_id = None

response_clients = client.list_user_pool_clients(UserPoolId=user_pool_id, MaxResults=60)

for client_info in response_clients["UserPoolClients"]:
    if client_info["ClientName"] == CLIENT_NAME:
        existing_client_id = client_info["ClientId"]
        logger.info(f"App Client '{CLIENT_NAME}' already exists with ID: {existing_client_id}")
        break

if existing_client_id:
    app_client_id = existing_client_id
else:
    logger.info("Creating new App Client...")
    response_client = client.create_user_pool_client(
        UserPoolId=user_pool_id,
        ClientName=CLIENT_NAME,
        GenerateSecret=False,
        ExplicitAuthFlows=[
            "ALLOW_USER_PASSWORD_AUTH",
            "ALLOW_REFRESH_TOKEN_AUTH",
            "ALLOW_USER_SRP_AUTH",
            "ALLOW_CUSTOM_AUTH",
        ],
        AllowedOAuthFlows=["code"],
        AllowedOAuthScopes=["email", "openid", "profile"],
        AllowedOAuthFlowsUserPoolClient=True,
        CallbackURLs=["http://localhost:8501"],
        LogoutURLs=["http://localhost:8501/logout"],
    )
    app_client_id = response_client["UserPoolClient"]["ClientId"]
    logger.success(f"App Client created with ID: {app_client_id}")

output = {
    "UserPoolId": user_pool_id,
    "AppClientId": app_client_id,
}

logger.info(f"Output: {json.dumps(output, indent=4)}")

This script performs the tasks:

  1. Checks if a user pool with the specified name exists. If not, it creates a new one and configures it with the necessary settings, including password policies and email verification.

  2. Checks if an App Client is already defined. If it does not exist, the script creates it with appropriate OAuth2 and authentication settings.

  3. Output the configuration details in a JSON format.

Create user pool

Once you have the UserPoolId and AppClientId, add them to your .env file:

USER_POOL_ID=your-user-pool-id
CLIENT_ID=your-app-client-id

The UserPoolId and AppClientId can also be found in the AWS Console:

User pool

![Client id] (//images.ctfassets.net/il1yandlcjgk/HbIHsGjzLFRy9YYBY52pt/50569a01fe694288573329dd85568f72/2025-04-23-client-id.png){: .zoomable }

The ExplicitAuthFlows parameter specifies which authentication flows are allowed when users sign in directly with a Cognito User Pool, such as using a username and password.

The AllowedOAuthScopes parameter defines what information your app is permitted to access when users authenticate via the Hosted UI using a third-party identity provider,such as Google, which you will set up later. In this case, the email, openid, and profile scopes enable your app to retrieve the user’s email address and basic profile information.

The App Client is configured to use the Authorization Code Grant OAuth2 flow, one of the recommended and most secure flows for web applications, and the CallbackURLs tell Cognito where to redirect users after they successfully sign in through the Hosted UI. The ``LogoutURLs` parameter specifies where to redirect users after they sign out.

Set up the hosted UI domain

Next, run the second script to create a domain for Cognito’s Hosted UI:

uv run src/hosted_domain.py
import os

import boto3
import dotenv
from botocore.exceptions import ClientError
from loguru import logger

dotenv.load_dotenv()

AWS_REGION = os.getenv("AWS_REGION")
POOL_NAME = os.getenv("POOL_NAME")
DOMAIN_PREFIX = os.getenv("DOMAIN_PREFIX")

if not AWS_REGION:
    raise ValueError("Missing AWS_REGION in .env file")
if not POOL_NAME:
    raise ValueError("Missing POOL_NAME in .env file")
if not DOMAIN_PREFIX:
    raise ValueError("Missing DOMAIN_PREFIX in .env file")

# Set up logging
logger.info("Starting Hosted UI Domain setup script...")

# Create Cognito client
client = boto3.client(service_name="cognito-idp", region_name=AWS_REGION)

# Step 1: Check if the user pool exists and get its ID
logger.info(f"Checking if user pool '{POOL_NAME}' exists...")

existing_pool_id = None
paginator = client.get_paginator("list_user_pools")
for page in paginator.paginate(MaxResults=60, PaginationConfig={"MaxItems": 10}):  # type: ignore
    for pool in page["UserPools"]:
        if pool["Name"] == POOL_NAME:
            existing_pool_id = pool["Id"]
            logger.info(f"User pool '{POOL_NAME}' exists with ID: {existing_pool_id}")
            break
    if existing_pool_id:
        break

if not existing_pool_id:
    logger.error(f"User pool '{POOL_NAME}' does not exist.")
    raise ValueError(f"User pool '{POOL_NAME}' not found. Please create it first.")

user_pool_id = existing_pool_id

# Step 2: Check if domain already exists
try:
    response = client.describe_user_pool_domain(Domain=DOMAIN_PREFIX)
    domain_description = response.get("DomainDescription", {})

    if domain_description.get("Domain") == DOMAIN_PREFIX and domain_description.get("Status") == "ACTIVE":
        logger.info(f"Hosted UI domain '{DOMAIN_PREFIX}' already exists and is active")
    else:
        # Create the domain since the description is empty
        try:
            logger.info(f"Creating new Hosted UI domain '{DOMAIN_PREFIX}'...")
            client.create_user_pool_domain(Domain=DOMAIN_PREFIX, UserPoolId=user_pool_id)
            logger.success(f"Hosted UI domain created: https://{DOMAIN_PREFIX}.auth.{AWS_REGION}.amazoncognito.com")
        except ClientError as create_error:
            logger.error(f"Error creating Hosted UI domain: {create_error}")
            raise
except ClientError as e:
    if e.response["Error"]["Code"] == "ResourceNotFoundException":
        # Step 3: Create the Hosted UI domain if it doesn't exist
        try:
            logger.info(f"Creating new Hosted UI domain '{DOMAIN_PREFIX}'...")
            client.create_user_pool_domain(Domain=DOMAIN_PREFIX, UserPoolId=user_pool_id)
            logger.success(f"Hosted UI domain created: https://{DOMAIN_PREFIX}.auth.{AWS_REGION}.amazoncognito.com")
        except ClientError as create_error:
            logger.error(f"Error creating Hosted UI domain: {create_error}")
            raise
    else:
        logger.error(f"Error checking domain existence: {e}")
        raise

# Final output
domain_url = f"https://{DOMAIN_PREFIX}.auth.{AWS_REGION}.amazoncognito.com"
logger.info("Hosted UI domain setup completed successfully.")
logger.info(f"Hosted UI domain URL: {domain_url}")

This script:

  1. Verifies that the Hosted UI domain already exists.

  2. Creates the domain if it does not exist.

  3. Confirms the domain is active and ready to use.

Once the script finishes, you will get a URL with your domain prefix and your region.

Create hosted domain

You will use this domain to redirect users to Cognito’s login interface.

Domain

Adding authentication methods

To let users sign in with either a Cognito User Pool (username/password) or Google, you need to:

  1. Create a Google OAuth2 client.

  2. Register Google as an identity provider in the user pool.

  3. Update the user pool app client to support both providers and configure OAuth2 properly.

Create a Google OAuth2 client

In the Google Cloud Console, create a new project and then navigate to APIs & Services -> Credentials -> Create Credentials -> OAuth client ID. Choose Web Application as the application type. Under Authorized redirect URIs, add the redirect URI for your Cognito Hosted UI, which is the domain of your Cognito Hosted UI + /oauth2/idpresponse: https://<DOMAIN_PREFIX>.auth.<AWS_REGION>.amazoncognito.com/oauth2/idpresponse.

Google IDP GCP

Once created, copy the Client ID and Client Secret into your .env file as:

GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

Register Google as an identity provider

Next, you need to add the Google Identity Provider (IdP) to your AWS Cognito User Pool. Run this script:

uv run src/google_idp.py
import os

import boto3
import dotenv
from botocore.exceptions import ClientError
from loguru import logger

# Load environment variables
dotenv.load_dotenv()

AWS_REGION = os.getenv("AWS_REGION", "eu-central-1")
USER_POOL_ID = os.getenv("USER_POOL_ID")
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")

# Create Cognito client
client = boto3.client(service_name="cognito-idp", region_name=AWS_REGION)

# Ensure required parameters are provided
if not USER_POOL_ID or not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET:
    raise ValueError("Missing one or more required environment variables: USER_POOL_ID, CLIENT_ID, CLIENT_SECRET")

# Check if Google Identity Provider exists
try:
    response = client.describe_identity_provider(UserPoolId=USER_POOL_ID, ProviderName="Google")
    logger.info("Google Identity Provider already exists")

    # Verify if the configuration matches
    provider_details = response["IdentityProvider"]["ProviderDetails"]
    if provider_details["client_id"] != GOOGLE_CLIENT_ID or provider_details["client_secret"] != GOOGLE_CLIENT_SECRET:
        logger.warning("Existing Google IdP has different client credentials. Updating...")
        client.update_identity_provider(
            UserPoolId=USER_POOL_ID,
            ProviderName="Google",
            ProviderDetails={
                "client_id": GOOGLE_CLIENT_ID,
                "client_secret": GOOGLE_CLIENT_SECRET,
                "authorize_scopes": "openid email profile",
            },
            AttributeMapping={
                "email": "email",
                "username": "sub",
                "given_name": "given_name",
                "family_name": "family_name",
            },
        )
        logger.success("Google Identity Provider updated successfully")
    else:
        logger.info("Existing Google IdP configuration is up to date")

except ClientError as e:
    if e.response["Error"]["Code"] == "ResourceNotFoundException":
        # Create Google identity provider if it doesn't exist
        try:
            logger.info("Creating new Google Identity Provider...")
            response = client.create_identity_provider(
                UserPoolId=USER_POOL_ID,
                ProviderName="Google",
                ProviderType="Google",
                ProviderDetails={
                    "client_id": GOOGLE_CLIENT_ID,
                    "client_secret": GOOGLE_CLIENT_SECRET,
                    "authorize_scopes": "openid email profile",
                },
                AttributeMapping={
                    "email": "email",
                    "username": "sub",
                    "given_name": "given_name",
                    "family_name": "family_name",
                },
            )
            logger.success("Successfully created Google Identity Provider")
        except ClientError as create_error:
            logger.error(f"Failed to create Google Identity Provider: {str(create_error)}")
            raise
    else:
        logger.error(f"Error checking Google Identity Provider: {str(e)}")
        raise

This script:

  1. Checks if the Google IdP already exists in your user pool.

  2. Update it if the credentials do not match.

  3. Creates it from scratch if it does not exist.

Google IDP

It also sets up attribute mapping from Google to Cognito, using standard OpenID scopes.

After running the script, you should see the Google IdP in the AWS Cognito Console under Social and external providers in your user pool with the corresponding client ID, secret and attribute mapping.

Google IDP AWS

Update the user pool app client

Now, you need to enable the app client to support login via both Cognito username/password and Google. By default, it is set with status Unavailable. You can find the configuration in the AWS Cognito Console by clicking App Clients -> Login Pages.

Client login deactivated

To do this, run the following script:

uv run src/client_login_pages.py

This script updates the User Pool App Client keeping the previous configuration and adding the allowed login methods Cognito username/password and Google as IdP.

import os

import boto3
import dotenv
from botocore.exceptions import ClientError
from loguru import logger

# Load environment variables
dotenv.load_dotenv()

# Get environment variables
USER_POOL_ID = os.getenv("USER_POOL_ID")
CLIENT_ID = os.getenv("CLIENT_ID")
AWS_REGION = os.getenv("AWS_REGION", "eu-central-1")

# Ensure required parameters are provided
if not USER_POOL_ID or not CLIENT_ID:
    raise ValueError("Missing required environment variables: USER_POOL_ID, CLIENT_ID")

# Create Cognito client
client = boto3.client(service_name="cognito-idp", region_name=AWS_REGION)

try:
    logger.info("Updating User Pool Client configuration...")
    response = client.update_user_pool_client(
        UserPoolId=USER_POOL_ID,
        ClientId=CLIENT_ID,
        SupportedIdentityProviders=["COGNITO", "Google"],
        CallbackURLs=["http://localhost:8501"],
        LogoutURLs=["http://localhost:8501/logout"],
        AllowedOAuthFlows=["code"],
        AllowedOAuthScopes=["email", "openid", "profile"],
        AllowedOAuthFlowsUserPoolClient=True,
    )

    # Verify the update was successful
    if "UserPoolClient" in response:
        logger.success("Successfully updated User Pool Client configuration")
        logger.info(f"Client ID: {response['UserPoolClient']['ClientId']}")
        logger.info(f"Supported Identity Providers: {response['UserPoolClient']['SupportedIdentityProviders']}")
    else:
        logger.warning("Update successful but response format unexpected")

except ClientError as e:
    error_code = e.response["Error"]["Code"]
    error_message = e.response["Error"]["Message"]
    logger.error(f"Failed to update User Pool Client. Error {error_code}: {error_message}")
    raise

except Exception as e:
    logger.error(f"Unexpected error occurred: {str(e)}")
    raise

To see the updated configuration, click Edit in the Login Pages section. Also the status should be Available.

Client login activated

Building the Streamlit application

Now that your authentication flow is configured, you will build a Streamlit application that integrates with your Cognito User Pool. This application uses the Authorization Code Grant flow to securely authenticate users and display their tokens.

To start the application, run the following command:

uv run streamlit run app/main.py
import os

import requests
import streamlit as st
from dotenv import load_dotenv

# Load environment variables
load_dotenv()

# Set up constants from environment variables
CLIENT_ID = os.getenv("CLIENT_ID")
REDIRECT_URI = os.getenv("REDIRECT_URI")
DOMAIN_PREFIX = os.getenv("DOMAIN_PREFIX")
AWS_REGION = os.getenv("AWS_REGION")
COGNITO_DOMAIN = f"https://{DOMAIN_PREFIX}.auth.{AWS_REGION}.amazoncognito.com"

# Cognito token endpoint
TOKEN_URL = f"{COGNITO_DOMAIN}/oauth2/token"

# Handle redirect URI (authorization code flow)
def get_auth_code() -> str | None:
    # Use Streamlit's query_params to get the URL parameters
    query_params = st.query_params
    # Get the code parameter, explicitly cast to str or None
    code: str | None = query_params.get("code", None)
    return code

# Exchange the authorization code for tokens
def exchange_code_for_tokens(auth_code: str) -> dict[str, str] | None:
    data = {
        "grant_type": "authorization_code",
        "code": auth_code,
        "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID,
    }

    response = requests.post(TOKEN_URL, data=data)
    if response.status_code == 200:
        tokens = response.json()
        return dict(tokens)
    else:
        st.error("Failed to get tokens")
        return None

# Main app
def main() -> None:
    # Check if user is already authenticated
    auth_code = get_auth_code()

    if auth_code:
        # Exchange auth code for tokens
        tokens = exchange_code_for_tokens(auth_code)

        if tokens:
            st.success("Successfully authenticated!")
            st.write("Access Token:", tokens["access_token"])
            st.write("ID Token:", tokens["id_token"])

            # Add logout button
            logout_url = f"{COGNITO_DOMAIN}/logout?client_id={CLIENT_ID}&logout_uri=http://localhost:8501/logout"

            st.markdown(f"[Logout]({logout_url})")
        else:
            st.error("Failed to authenticate.")
    else:
        # Show the login button to redirect to Cognito Hosted UI
        st.title("Welcome to Streamlit App!")
        st.write("Please log in to continue.")
        base_url = f"{COGNITO_DOMAIN}/login?response_type=code&client_id={CLIENT_ID}"
        cognito_url = f"{base_url}&redirect_uri={REDIRECT_URI}&scope=email+openid+profile"
        st.markdown(f"[Login with Cognito]({cognito_url})")

if __name__ == "__main__":
    main()

This application performs these steps:

  • Displays a log-in button that redirects the user to the Cognito Hosted UI.
  • Handles the redirect back to the application with an authorization code.
  • Exchanges the authorization code for access and ID tokens.
  • Displays the tokens and provides a log-out button.

This logic ensures that your app is secure and is accessible only after authentication.

To test that the application is working, click the login button on the main page.

Login button

You will be redirected to your Cognito Hosted UI, where you can sign in with your Google account or create a new account using your email and password.

Cognito-hosted UI

If you sign in with your Google account, you will be prompted to grant access to your account information. Once accepted, you will be redirected back to the application, and your access and ID tokens will be displayed on the main page.

Streamlit log-in success Google

If you sign up using your email and password for the first time, you must verify your account. After entering your email and password, a verification code will be sent to your email address. After entering the code, you will be redirected back to the application and your access and ID tokens will be displayed.

Streamlit verification

Each time a new user signs up in the application, a new user is also created in the Cognito user pool. You can see the list of users in the AWS Cognito Console under Users.

Cognito users

Finally, you can click the Logout button to securely log out of the application and be redirected back to the log-in page.

Writing tests for the application

To ensure your application is reliable and secure, you should write both unit tests and integration tests. These tests verify that your Cognito integration, token handling, and Streamlit login flow work as expected.

You will use pytest to run your tests. Us this command:

uv run pytest 

Two types of tests will be performed to check the configuration of your Cognito user pool, app client, and identity provider and the functionality of your Streamlit application.

  1. Integration tests using test_cognito_app.py: contains tests that verify the configuration of your Cognito user pool, app client, and identity provider is correct.

     import os
     from typing import Any
    
     import boto3
     import dotenv
     import pytest
     from botocore.exceptions import ClientError
     from mypy_boto3_cognito_idp import CognitoIdentityProviderClient
    
     dotenv.load_dotenv()
    
     USER_POOL_ID = os.getenv("USER_POOL_ID")
     CLIENT_ID = os.getenv("CLIENT_ID")
     GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
    
     @pytest.fixture
     def cognito_idp_client() -> Any:
         client = boto3.client("cognito-idp", region_name="eu-central-1")
         return client
    
     def test_user_pool_and_app_client_exist(cognito_idp_client: CognitoIdentityProviderClient) -> None:
         assert USER_POOL_ID is not None, "USER_POOL_ID is not set in .env"
         assert CLIENT_ID is not None, "CLIENT_ID is not set in .env"
         assert GOOGLE_CLIENT_ID is not None, "GOOGLE_CLIENT_ID is not set in .env"
    
         # Check if User Pool exists
         try:
             pool_response = cognito_idp_client.describe_user_pool(UserPoolId=USER_POOL_ID)
             assert "UserPool" in pool_response
             assert pool_response["UserPool"]["Id"] == USER_POOL_ID
             print(f"✅ User Pool '{USER_POOL_ID}' exists.")
         except ClientError as e:
             if e.response["Error"]["Code"] == "ResourceNotFoundException":
                 pytest.fail(f"❌ User Pool '{USER_POOL_ID}' does not exist.")
             else:
                 pytest.fail(f"⚠️ Error while fetching User Pool: {str(e)}")
    
         # Check if App Client exists
         try:
             client_response = cognito_idp_client.describe_user_pool_client(UserPoolId=USER_POOL_ID, ClientId=CLIENT_ID)
             assert "UserPoolClient" in client_response
             assert client_response["UserPoolClient"]["ClientId"] == CLIENT_ID
             print("✅ App Client exists in User Pool.")
         except ClientError as e:
             if e.response["Error"]["Code"] == "ResourceNotFoundException":
                 pytest.fail(f"❌ App Client '{CLIENT_ID}' does not exist in User Pool '{USER_POOL_ID}'.")
             else:
                 pytest.fail(f"⚠️ Error while fetching App Client: {str(e)}")
    
         # Check if Google Identity Provider exists and has correct client ID
         try:
             idp_response = cognito_idp_client.describe_identity_provider(UserPoolId=USER_POOL_ID, ProviderName="Google")
             google_idp = idp_response["IdentityProvider"]
             assert google_idp["ProviderType"] == "Google", "ProviderType is not Google"
             configured_client_id = google_idp["ProviderDetails"].get("client_id")
             assert configured_client_id == GOOGLE_CLIENT_ID, (
                 f"Google IdP client_id mismatch: expected '{GOOGLE_CLIENT_ID}', got '{configured_client_id}'"
             )
             print("✅ Google Identity Provider exists and has correct client ID.")
         except ClientError as e:
             if e.response["Error"]["Code"] == "ResourceNotFoundException":
                 pytest.fail("❌ Google Identity Provider does not exist.")
             else:
                 pytest.fail(f"⚠️ Error while fetching Identity Provider: {str(e)}")
    
  2. Unit tests using test_streamlit_app.py: contains unit tests that mock Streamlit to test the functions responsible for token exchange and URL query parsing logic.

     import os
     import sys
    
     sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
    
     from typing import Any
     from unittest.mock import Mock, patch
    
     import pytest
    
     from app.main import exchange_code_for_tokens, get_auth_code
    
     @pytest.fixture(autouse=True)
     def mock_streamlit(mocker: Any) -> None:
         mocker.patch("app.main.st")
    
     @patch("app.main.requests.post")
     def test_exchange_code_for_tokens_success(mock_post: Mock) -> None:
         """Test successful token exchange with valid authorization code."""
         mock_response = mock_post.return_value
         mock_response.status_code = 200
         mock_response.json.return_value = {"access_token": "fake_access_token", "id_token": "fake_id_token"}
    
         result_tokens: dict[str, str] | None = exchange_code_for_tokens("test_code")
         assert result_tokens is not None
         assert result_tokens["access_token"] == "fake_access_token"
         assert result_tokens["id_token"] == "fake_id_token"
         print("✅ Token exchange successful.")
    
     @patch("app.main.requests.post")
     def test_exchange_code_for_tokens_failure(mock_post: Mock) -> None:
         """Test failed token exchange with invalid authorization code."""
         mock_response = mock_post.return_value
         mock_response.status_code = 400
         mock_response.text = "Bad request"
    
         result_tokens: dict[str, str] | None = exchange_code_for_tokens("bad_code")
         assert result_tokens is None
         print("✅ Token exchange failed as expected.")
    
     def test_get_auth_code_success(mocker: Any) -> None:
         """Test successful retrieval of authorization code from query parameters."""
         mocker.patch("app.main.st.query_params", {"code": "test_auth_code"})
         auth_code: str | None = get_auth_code()
         assert auth_code == "test_auth_code"
         print("✅ Authorization code retrieved successfully.")
    
     def test_get_auth_code_missing(mocker: Any) -> None:
         """Test handling of missing authorization code in query parameters."""
         mocker.patch("app.main.st.query_params", {})
         auth_code: str | None = get_auth_code()
         assert auth_code is None
         print("✅ Missing authorization code handled successfully.")
    

With these tests in place, you can verify that your AWS Cognito configuration is correct and your login logic in Streamlit works as expected under different scenarios.

Tests

Deploying the application with CircleCI

To automate the deployment pipeline using CircleCI, you need to define a CircleCI config file (.config.yml). This pipeline will handle tasks like installing dependencies, validating your code, configuring AWS resources, and running tests.

Before CircleCI can deploy your application to AWS, you must configure the required environment variables. Go to your CircleCI dashboard, create a new project and link it to your GitHub repository. Then, go to your project settings, and add the same variables you defined in your .env file. Add the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. These variables are needed for AWS authentication and for the scripts to configure Cognito correctly.

circle_env

You also need to create a pipeline in your project and set up a trigger for the pipeline. If you want to trigger the pipeline on each push to the main branch, you can set up an all pushes event.

circle_pipeline

circle_trigger

Once the environment variables, the pipeline, and the trigger are set up, you can define the CircleCI config file as follows:

version: 2.1

orbs:
  aws-cli: circleci/aws-cli@5.3.1

jobs:
  build-deploy:
    docker:
      - image: cimg/python:3.12
    steps:
      - checkout

      - run:
          name: Install UV
          command: |
            curl -LsSf https://astral.sh/uv/install.sh | sh

      - run:
          name: Create venv and install dependencies
          command: |
            uv sync --all-groups

      - run:
          name: Run ruff
          command: |
            uv run ruff check . --fix --exit-non-zero-on-fix

      - run:
          name: Run MyPy
          command: |
            uv run mypy

      - aws-cli/setup:
          profile_name: default

      - run:
          name: Configure Cognito App
          command: |
            uv run src/cognito_app.py

      - run:
          name: Configure Hosted Domain
          command: |
            uv run src/hosted_domain.py

      - run:
          name: Configure Google IDP
          command: |
            uv run src/google_idp.py

      - run:
          name: Configure Client Login Pages
          command: |
            uv run src/client_login_pages.py

      - run:
          name: Run tests
          command: |
            uv run pytest

workflows:
  deploy:
    jobs:
      - build-deploy

What this configuration does:

  • Orbs: The aws-cli orb sets up the AWS CLI so you can interact with AWS services like Cognito.

  • Job (build-deploy): This job includes all the deployment steps:

    1. Checks out your code.
    2. Installs uv for managing Python dependencies.
    3. Syncs and installs dependencies from pyproject.toml.
    4. Runs ruff to automatically lint and fix Python code.
    5. Runs mypy for static type checking.
    6. Sets up the AWS CLI environment.
    7. Runs the previous Python scripts to configure Cognito, hosted domain, Google IDP, and client login pages.
    8. Finally, it runs your test suite using pytest.
  • Workflow (deploy) This workflow triggers the build-deploy job every time you push new changes to your repository.

Once this configuration is committed and pushed to your GitHub repository, CircleCI will automatically kick off the deployment process. You will be able to monitor the steps directly in the CircleCI dashboard. If everything is set up correctly, you should see a green build.

circle_build_deploy

Cleaning up

If you do not need the app anymore, make sure you delete the resources to avoid unnecessary charges.

Conclusion

In this tutorial, you have built a secure authentication system by integrating AWS Cognito with a Streamlit application using the Authorization Code Grant flow. You configured AWS Cognito to support both email/password sign-up and Google sign-in, then integrated it into a Python app with environment-based configuration. You wrote unit tests for authentication logic, validated infrastructure, and ensured robust session handling with test coverage.

You then automated the deployment pipeline using CircleCI, streamlining everything from dependency installation and static analysis to resource provisioning and test execution. By combining AWS Cognito with CircleCI, you have gained the ability to scale authentication securely while maintaining high development velocity. With CI/CD in place, every commit will be tested and deployed consistently, making your development process more reliable and repeatable.