Skip to content

Testing Cognito Authentication Flows Locally with LocalStack

Learn how to build and test Cognito flows against LocalStack: the hosted UI with auth code and PKCE, JWT verification against the pool's JWKS in an API Gateway authorizer, TOTP multi-factor auth, and machine-to-machine tokens from the client credentials grant.

LocalStack
Sep 25, 202613 min read

Introduction

Authentication is often the last part of an application to run locally. Buckets and tables run in a container, but the user pool stays in a shared AWS account. Developers share test users and app clients, while integration tests either skip login or store a real password in CI.

These workarounds can mean that real problems in the authentication flow only show up in production. LocalStack solves this by letting you run Amazon Cognito user pools on your machine. You can create pools and app clients, register users, test OAuth flows, verify tokens, and answer MFA challenges without using a shared AWS account.

In this tutorial, you will build an expense claims portal that covers four common Cognito flows:

  • Employees sign in through the hosted UI with authorization code and PKCE.
  • API Gateway verifies each token before it invokes Lambda.
  • Finance admins use a time-based one-time password (TOTP) authenticator as a second factor.
  • A payroll job uses client credentials with one custom scope.

Terraform creates the infrastructure. A React app acts as the employee and admin clients, while a Node.js script acts as the payroll job.

Cognito on LocalStack

A Cognito user pool is both a user directory and a token issuer. After a user signs in, Cognito can return three tokens:

  • The ID token describes the user. It includes attributes such as email and cognito:groups.
  • The access token is sent to an API. It includes claims such as client_id, scope, and groups.
  • The refresh token gets new ID and access tokens.

Applications can sign users in through the hosted UI or the user pool API. With the hosted UI, the browser opens /oauth2/authorize and returns with a one-time code. The app exchanges that code at /oauth2/token. With the user pool API, calls such as InitiateAuth and RespondToAuthChallenge handle sign-in and MFA directly.

Each pool also publishes:

  • An OpenID Connect discovery document at /<pool id>/.well-known/openid-configuration
  • Signing keys at /<pool id>/.well-known/jwks.json

Token verifiers use these keys to check the signature, expiry, and issuer without calling Cognito for every request.

LocalStack serves these endpoints at localhost.localstack.cloud:4566. The AWS CLI, SDKs, and Terraform use the same APIs as AWS. By default, verification codes appear in the LocalStack logs instead of an inbox.

The sample does not hard-code the OAuth endpoints. It discovers them from the pool issuer. This means you only need to change the issuer when moving from LocalStack to AWS.

Prerequisites

  • lstk with a LocalStack auth token. Cognito is available in paid tiers or as part of an active trial.
  • Docker
  • Node.js 22.12 or later
  • Terraform 1.5 or later
  • jq for readable JSON output
  • An authenticator app such as Google Authenticator or 1Password. The repository also ships a script that prints TOTP codes, so you can stay in the terminal.

Step 1: Deploy the sample

1.1: Start LocalStack

The single-page application (SPA) runs at http://localhost:5173 and calls LocalStack from the browser. Add this origin to LocalStack's CORS allow list:

LOCALSTACK_EXTRA_CORS_ALLOWED_ORIGINS=http://localhost:5173 lstk start

Without this setting, LocalStack returns 403 before Cognito receives the browser request.

1.2: Clone and deploy

git clone https://github.com/localstack-samples/sample-terraform-cognito-expense-claims.git
cd sample-terraform-cognito-expense-claims
make install
make deploy

make deploy builds the Lambda function, applies the Terraform configuration through lstk terraform, and writes the outputs to .env and web/.env.local. The wrapper points the AWS provider at LocalStack, so the Terraform files contain no hard-coded LocalStack endpoints. On our machine, deployment took about 25 seconds:

Apply complete! Resources: 19 added, 0 changed, 0 destroyed.
Wrote .env and web/.env.local
  COGNITO_ISSUER=http://localhost.localstack.cloud:4566/us-east-1_b959df84c022480f8ebcf3934e18f15a
  COGNITO_ENDPOINT=http://localhost.localstack.cloud:4566
  COGNITO_USER_POOL_ID=us-east-1_b959df84c022480f8ebcf3934e18f15a
  COGNITO_SPA_CLIENT_ID=8gubw8gzym1cwx0iedasjkp472
  API_URL=http://04e769ad.execute-api.localhost.localstack.cloud:4566
  PAYROLL_EXPORT_CLIENT_ID=uwvgx2xdbxiaihrefss1m39oti
  PAYROLL_EXPORT_CLIENT_SECRET=<hidden>

Load the values into your shell:

set -a; source .env; set +a

This gives each developer an isolated user pool and API in seconds. You can change the Terraform configuration, redeploy, and test again without coordinating callback URLs, test users, or MFA settings in a shared AWS account.

1.3: Read the Terraform configuration

The pool is defined in terraform/cognito.tf. Users sign in with an email address. TOTP is available, but each user chooses whether to enable it:

# terraform/cognito.tf
resource "aws_cognito_user_pool" "pool" {
  name                     = "expenses-users"
  username_attributes      = ["email"]
  auto_verified_attributes = ["email"]
  mfa_configuration        = "OPTIONAL"

  software_token_mfa_configuration {
    enabled = true
  }
}

With OPTIONAL, Cognito asks for a second factor only after a user registers one. With ON, every user must set one up. This sample keeps MFA optional so employees can use a password while finance admins use TOTP.

The SPA client is public, so it has no secret. Browsers cannot safely store secrets. This client supports authorization code login and direct password login:

# terraform/cognito.tf
resource "aws_cognito_user_pool_client" "spa" {
  name         = "expenses-spa"
  user_pool_id = aws_cognito_user_pool.pool.id

  generate_secret                      = false
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["openid", "email", "profile", "aws.cognito.signin.user.admin"]
  callback_urls                        = ["http://localhost:5173/callback"]
  logout_urls                          = ["http://localhost:5173/"]

  explicit_auth_flows = ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"]
}

The aws.cognito.signin.user.admin scope allows users to update their own account. It is required for calls such as AssociateSoftwareToken and SetUserMFAPreference. Without it, the Security page fails on AWS with NotAuthorizedException.

The payroll client has a secret and only supports the client credentials grant. Its custom scope comes from a Cognito resource server:

# terraform/cognito.tf
resource "aws_cognito_resource_server" "expenses" {
  identifier   = "expenses"
  name         = "Expenses API"
  user_pool_id = aws_cognito_user_pool.pool.id

  scope {
    scope_name        = "read"
    scope_description = "Read approved claims"
  }
}

resource "aws_cognito_user_pool_client" "payroll_export" {
  name         = "expenses-payroll-export"
  user_pool_id = aws_cognito_user_pool.pool.id

  generate_secret                      = true
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["client_credentials"]
  allowed_oauth_scopes                 = ["expenses/read"]
}

The full scope is expenses/read. It appears in the export token's scope claim.

terraform/api.tf protects the HTTP API with a JWT authorizer:

# terraform/api.tf
resource "aws_apigatewayv2_authorizer" "cognito" {
  api_id           = aws_apigatewayv2_api.api.id
  name             = "cognito-jwt"
  authorizer_type  = "JWT"
  identity_sources = ["$request.header.Authorization"]

  jwt_configuration {
    issuer   = local.issuer
    audience = [aws_cognito_user_pool_client.spa.id, aws_cognito_user_pool_client.payroll_export.id]
  }
}

For LocalStack, local.issuer is http://localhost.localstack.cloud:4566/<pool id>. On AWS, it is https://cognito-idp.<region>.amazonaws.com/<pool id>. Cognito access tokens identify the app client with client_id, which API Gateway accepts as an audience value.

Four routes use this authorizer. Three accept any valid token from the pool. GET /claims/approved also requires the expenses/read scope, so SPA users cannot call it. The Lambda function trusts the verified claims from API Gateway and uses them for authorization.

1.4: Read what the pool publishes

The discovery document lists the pool's OAuth and token endpoints:

curl -s "$COGNITO_ISSUER/.well-known/openid-configuration" | jq
{
  "authorization_endpoint": "http://localhost.localstack.cloud:4566/_aws/cognito-idp/oauth2/authorize",
  "issuer": "http://localhost.localstack.cloud:4566/us-east-1_b959df84c022480f8ebcf3934e18f15a",
  "jwks_uri": "http://localhost.localstack.cloud:4566/us-east-1_b959df84c022480f8ebcf3934e18f15a/.well-known/jwks.json",
  "token_endpoint": "http://localhost.localstack.cloud:4566/_aws/cognito-idp/oauth2/token",
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
  "userinfo_endpoint": "http://localhost.localstack.cloud:4566/_aws/cognito-idp/oauth2/userInfo",
  "end_session_endpoint": "http://localhost.localstack.cloud:4566/_aws/cognito-idp/logout",
  ...
}

The jwks_uri returns the public signing key. A token's kid header tells the verifier which key to use.

Step 2: Sign in through the hosted UI

2.1: Start the SPA

make dev

Open http://localhost:5173. The app offers hosted UI login for employees and password login for finance admins.

The SPA uses oidc-client-ts for the hosted UI flow. Its configuration in web/src/auth/oidc.ts includes the issuer, client ID, callback URL, and scopes:

export const userManager = new UserManager({
  authority: config.issuer,
  client_id: config.clientId,
  redirect_uri: `${window.location.origin}/callback`,
  response_type: "code",
  scope: "openid email profile aws.cognito.signin.user.admin",
  loadUserInfo: false,
  userStore: new WebStorageStateStore({ store: window.sessionStorage }),
});

The library gets the authorize and token endpoints from the discovery document. It also handles PKCE. Before redirecting, it creates a random verifier and sends its hash as the code_challenge. After Cognito returns a code, the library sends both the code and the original verifier to the token endpoint. A stolen code cannot be used without that verifier.

LocalStack accepts the PKCE parameters but does not yet check the code_verifier at the token endpoint, so keep sending it and rely on AWS to enforce it.

2.2: Sign up and confirm an employee

Click Sign in with hosted UI. LocalStack opens its login page with the client ID, callback URL, state, and PKCE challenge in the query string.

Choose Sign in as different user?, then Sign up. Enter sam@example.com with the password Passw0rd1 and click Sign Up.

The account starts in the UNCONFIRMED state. LocalStack writes its verification code to the logs:

lstk logs -v | grep "Confirmation code"
2026-09-18T14:15:57.687  INFO --- [et.reactor-3] l.p.c.s.c.provider : Confirmation code for Cognito user sam@example.com: 813873

The -v flag includes INFO messages. Use the code to confirm the account:

lstk aws cognito-idp confirm-sign-up \
  --client-id "$COGNITO_SPA_CLIENT_ID" \
  --username sam@example.com \
  --confirmation-code 813873

In a full application, the SPA would provide a confirmation form. This tutorial uses the CLI to show where LocalStack puts the code.

2.3: Sign in and submit a claim

Return to the login page and click Sign In. LocalStack redirects the browser to the callback URL with a one-time code:

http://localhost:5173/callback?state=3c0daf087378439eb09148e52d2f5c15&code=795628

The SPA exchanges the code and PKCE verifier for tokens, then stores them in session storage. Enter a description and amount to submit a claim.

The SPA sends POST /claims with the access token in the Authorization header. API Gateway checks the token's signature, expiry, issuer, and client ID before it invokes Lambda. Lambda stores the claim under the token's sub value.

This local flow covers the browser redirect, token exchange, JWT authorizer, Lambda function, and DynamoDB write. You can test the complete authentication path after every change instead of waiting for an AWS deployment.

Step 3: Add a finance admin with TOTP

3.1: Create the admin

Sign out, then register fin@example.com through the hosted UI. Confirm the account with the code from the LocalStack logs.

Add the user to the finance admin group:

lstk aws cognito-idp admin-add-user-to-group \
  --user-pool-id "$COGNITO_USER_POOL_ID" \
  --username fin@example.com \
  --group-name finance-admins

The LocalStack Resource Browser now shows the pool, group, and both users. The usernames are UUIDs because email addresses are sign-in aliases, not usernames.

Sign in through the hosted UI as fin@example.com. The SPA reads the cognito:groups claim and shows a finance-admins badge. The claims table also shows an Approve button. Do not approve the claim yet.

3.2: Register an authenticator

Open Security and click Set up an authenticator app. The page shows a QR code and its secret.

Scan the QR code, or generate a code from the terminal:

npm run totp -- <secret shown on the page>
305180  (valid for 29s)

Enter the six-digit code and click Verify and enable. The page makes three calls from web/src/auth/cognito.ts:

  • AssociateSoftwareToken creates a secret for the user.
  • VerifySoftwareToken checks the first code.
  • SetUserMFAPreference sets TOTP as the preferred factor.

Each call uses the access token from the hosted UI login and requires the aws.cognito.signin.user.admin scope. Check the result:

lstk aws cognito-idp admin-get-user \
  --user-pool-id "$COGNITO_USER_POOL_ID" \
  --username fin@example.com \
  --query '{Status: UserStatus, MFA: UserMFASettingList, Preferred: PreferredMfaSetting}'
{
    "Status": "CONFIRMED",
    "MFA": ["SOFTWARE_TOKEN_MFA"],
    "Preferred": "SOFTWARE_TOKEN_MFA"
}

Because this user belongs to a local pool, you can delete and recreate it whenever you need to repeat enrolment or test a failed MFA setup. Other developers and shared test accounts are unaffected.

Step 4: Sign in with password and TOTP

4.1: Answer the challenge

Sign out and click Finance admin sign-in. This form calls the user pool API directly instead of using the hosted UI. A helper in web/src/auth/cognito.ts sends the same type of request as an AWS SDK:

// web/src/auth/cognito.ts
const result = await cognito<AuthResult>("InitiateAuth", {
  AuthFlow: "USER_PASSWORD_AUTH",
  ClientId: config.clientId,
  AuthParameters: { USERNAME: email, PASSWORD: password },
});
if (result.ChallengeName === "SOFTWARE_TOKEN_MFA") {
  return { kind: "totp-required", session: result.Session!, username: ... };
}

Enter the email and password. Because TOTP is enabled, InitiateAuth returns a SOFTWARE_TOKEN_MFA challenge and a Session value instead of tokens.

Generate a new TOTP code. The form sends it with the session:

// web/src/auth/cognito.ts
const result = await cognito<AuthResult>("RespondToAuthChallenge", {
  ClientId: config.clientId,
  ChallengeName: "SOFTWARE_TOKEN_MFA",
  Session: session,
  ChallengeResponses: { USERNAME: username, SOFTWARE_TOKEN_MFA_CODE: code },
});

An invalid code returns CodeMismatchException. A valid code returns the three tokens and signs the admin in.

This direct API flow is needed because LocalStack's hosted UI does not yet handle MFA challenges. The AWS hosted UI does.

4.2: Approve the claim

Click Approve next to the employee's claim. Lambda checks the caller's group first:

// api/src/index.ts
const groups = parseGroups(claims["cognito:groups"]);
if (!groups.includes(ADMIN_GROUP)) {
  return json(403, { message: `Only members of ${ADMIN_GROUP} may approve claims` });
}

API Gateway converts array claims to strings such as [finance-admins], so parseGroups converts the value back into a list. Because API Gateway already verified the token, Lambda does not need a database lookup for the group.

Step 5: Call the API as a machine

5.1: Get a token with client credentials

The payroll export runs without a user. It authenticates with the client ID and secret:

make export
Token for client_id=uwvgx2xdbxiaihrefss1m39oti scope="expenses/read" expires in 3600s

┌─────────┬────────────┬───────────────────┬────────┬────────────────────────────┐
│ (index) │ id         │ description       │ amount │ approvedAt                 │
├─────────┼────────────┼───────────────────┼────────┼────────────────────────────┤
│ 0       │ 'd9f5c8e0' │ 'Train to Berlin' │ 89.5   │ '2026-09-18T14:19:50.618Z' │
└─────────┴────────────┴───────────────────┴────────┴────────────────────────────┘

1 approved claim(s), total 89.50

The script in scripts/payroll-export.ts finds the token endpoint through discovery. It sends the client ID and secret with HTTP Basic authentication:

// scripts/payroll-export.ts
const basic = Buffer.from(`${PAYROLL_EXPORT_CLIENT_ID}:${PAYROLL_EXPORT_CLIENT_SECRET}`).toString("base64");
const tokenResponse = await fetch(discovery.token_endpoint, {
  method: "POST",
  headers: { authorization: `Basic ${basic}`, "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({ grant_type: "client_credentials", scope: "expenses/read" }),
});

Cognito returns only an access token. There is no ID token because no user signed in, and no refresh token because the client can request another access token. The token's sub is the client ID.

5.2: See the scope check at the gateway

The export route requires expenses/read. A user token from the SPA does not have this scope. Get an employee token and try the route:

USER_TOKEN=$(lstk aws cognito-idp initiate-auth \
  --client-id "$COGNITO_SPA_CLIENT_ID" \
  --auth-flow USER_PASSWORD_AUTH \
  --auth-parameters USERNAME=sam@example.com,PASSWORD=Passw0rd1 \
  --query AuthenticationResult.AccessToken --output text)

curl -s -w '\nHTTP %{http_code}\n' -H "Authorization: Bearer $USER_TOKEN" "$API_URL/claims/approved"
{"message":"Forbidden"}
HTTP 403

API Gateway returns 403 without invoking Lambda. It compares the token's scope claim with the route's required scopes. Decode the token to inspect those claims:

echo "$USER_TOKEN" | jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson
  | {token_use, client_id, scope, username}'
{
  "token_use": "access",
  "client_id": "8gubw8gzym1cwx0iedasjkp472",
  "scope": "aws.cognito.signin.user.admin",
  "username": "58016fe7-dba0-46cb-b643-50c196cacbbe"
}

The export token has the same issuer and signing key, but its client ID and scope are different. Those claims allow it to call the export route.

Running both requests locally makes the authorization boundary easy to test: the machine token succeeds, while the user token is rejected before Lambda runs. This is safer and faster than checking permission changes against a shared API.

Step 6: Run the integration test

Stop the dev server with Ctrl+C and run the test suite:

make test
 ✓ test/expenses.test.ts (8 tests) 24686ms
      Tests  8 passed (8)
   Duration  24.85s

The Vitest suite in test/expenses.test.ts runs the full flow without a browser. It:

  1. Creates an employee and admin.
  2. Submits a claim and confirms that the employee cannot approve it.
  3. Enables TOTP for the admin and answers the MFA challenge.
  4. Approves the claim.
  5. Confirms that only the client credentials token can use the export route.
  6. Deletes the test users and claim.

Most of the runtime is spent waiting for a new 30-second TOTP window so the test does not reuse a code.

The suite needs no AWS credentials or shared test users, so it can run on a developer machine or in CI. It also catches regressions across Cognito, API Gateway, Lambda, and DynamoDB in one test run.

Conclusion

You have now tested four Cognito flows locally:

  • Hosted UI login with authorization code and PKCE
  • JWT verification with an API Gateway authorizer
  • TOTP setup and MFA login
  • Client credentials with a custom scope

Terraform created the infrastructure, and the integration test repeated the full flow in about 25 seconds.

The result is a faster feedback loop with isolated users, repeatable infrastructure, and no dependency on a shared AWS environment. The same Terraform and application code can still be deployed to AWS when you are ready.

Next, you can add custom claims with a PreTokenGeneration Lambda trigger, replace password auth with SRP, or try email OTP. You can also use Cognito tokens with Amazon Verified Permissions. See the LocalStack Cognito documentation for supported operations.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions