| Key | Value |
|---|---|
| Services | Cognito, API Gateway (HTTP API), Lambda, DynamoDB |
| Integrations | Terraform, lstk, AWS SDK for JavaScript v3, Vite + React |
| Categories | Authentication; Serverless |
A small expense-claims portal that exercises the parts of Amazon Cognito most teams actually ship, entirely against LocalStack:
- Hosted UI with authorization code + PKCE. Employees sign up and sign in on Cognito's login page; the single-page app never sees a password and uses the pool's OpenID Connect discovery document for every URL.
- JWT verification with JWKS. An HTTP API with a JWT authorizer checks signature, expiry and audience against the pool's
jwks.jsonbefore a Lambda function runs. One route also requires a custom OAuth scope. - TOTP multi-factor authentication. Finance admins enrol an authenticator app from the SPA (
AssociateSoftwareToken→VerifySoftwareToken→SetUserMFAPreference) and afterwards answer aSOFTWARE_TOKEN_MFAchallenge when they sign in with a password. - Machine-to-machine access. A payroll export job gets a token with the
client_credentialsgrant, scoped toexpenses/read, and reads approved claims.
Employee ──hosted UI (code + PKCE)──▶ Cognito user pool ──JWKS──▶ HTTP API (JWT authorizer) ──▶ Lambda ──▶ DynamoDB
Finance admin ──password + TOTP (InitiateAuth / RespondToAuthChallenge)──▶ Cognito ─────────────▲
Payroll export ──client_credentials (expenses/read)──▶ Cognito token endpoint ───────────────────┘
Everything is created by Terraform through lstk terraform. The same configuration deploys to AWS with -var localstack=false.
lstkwith a LocalStack auth token (Cognito is part of the paid tiers)- Docker
- Node.js 22.12 or newer
- Terraform 1.5 or newer
- Optional: an authenticator app on your phone. The repo ships a script that generates TOTP codes if you prefer to stay on the keyboard.
Check that everything is present:
make checkThe SPA runs on http://localhost:5173 and calls Cognito's token endpoint straight from the browser. LocalStack only answers browsers whose origin is allow-listed, so pass the origin when you start it:
LOCALSTACK_EXTRA_CORS_ALLOWED_ORIGINS=http://localhost:5173 lstk start
# or: make startgit clone https://github.com/localstack-samples/sample-terraform-cognito-expense-claims.git
cd sample-terraform-cognito-expense-claims
make install # npm install
make deploy # bundles the Lambda, lstk terraform init + apply, writes .env and web/.env.localmake deploy ends with the values every other step needs:
COGNITO_ISSUER=http://localhost.localstack.cloud:4566/us-east-1_...
COGNITO_ENDPOINT=http://localhost.localstack.cloud:4566
COGNITO_USER_POOL_ID=us-east-1_...
COGNITO_SPA_CLIENT_ID=...
API_URL=http://....execute-api.localhost.localstack.cloud:4566
PAYROLL_EXPORT_CLIENT_ID=...
Have a look at what the pool publishes:
curl -s "$(grep COGNITO_ISSUER .env | cut -d= -f2)/.well-known/openid-configuration" | jqmake devOpen http://localhost:5173.
-
Click Sign in with hosted UI. You land on LocalStack's Cognito login page. If it offers "Sign in as existing user", choose Sign in as different user?.
-
Click Sign up, enter an e-mail address and a password (8+ characters with upper case, lower case and a digit), then Sign Up.
-
The account is
UNCONFIRMEDuntil the verification code is entered. LocalStack does not send e-mail unless SMTP is configured; the code is in the logs:lstk logs -v | grep "Confirmation code" lstk aws cognito-idp confirm-sign-up \ --client-id "$(grep COGNITO_SPA_CLIENT_ID .env | cut -d= -f2)" \ --username sam@example.com --confirmation-code 123456
-
Back on the login page, click Sign In. Cognito redirects to
/callback?code=…, the SPA exchanges the code (with the PKCE verifier) for tokens and shows the claims page. -
Submit a claim.
-
Sign out, sign up a second user the same way and confirm it.
-
Put the user in the
finance-adminsgroup:lstk aws cognito-idp admin-add-user-to-group \ --user-pool-id "$(grep COGNITO_USER_POOL_ID .env | cut -d= -f2)" \ --username fin@example.com --group-name finance-admins -
Sign in through the hosted UI. The header shows the group and the claims table lists every claim with an Approve button.
-
Open Security → Set up an authenticator app. Scan the QR code, or run the printed command:
npm run totp -- <secret shown on the page>
Enter the six-digit code. Cognito now lists
SOFTWARE_TOKEN_MFAas the user's preferred factor.
- Sign out and click Finance admin sign-in.
- Enter e-mail and password.
InitiateAuth(USER_PASSWORD_AUTH) returns aSOFTWARE_TOKEN_MFAchallenge instead of tokens. - Enter a fresh code from the authenticator (or
npm run totp -- <secret>).RespondToAuthChallengereturns the tokens. - Approve the employee's claim. The Lambda checks the
cognito:groupsclaim before updating DynamoDB.
make export
# or: npm run payroll-exportThe script discovers the token endpoint, sends client id and secret with the client_credentials grant and scope=expenses/read, and calls GET /claims/approved. The route carries authorization_scopes = ["expenses/read"], so user tokens from the SPA get a 403 at the gateway while the export token gets the list.
make testThe Vitest suite creates its own employee and admin (AdminCreateUser), enrols TOTP with otpauth, exercises submit → approve → export including the negative cases (401 for forged tokens, 403 for the wrong group and the wrong scope), then deletes what it created. It runs in about 15 seconds.
| Path | Purpose |
|---|---|
terraform/cognito.tf |
User pool, hosted UI domain, finance-admins group, resource server with the expenses/read scope, public SPA client, confidential payroll client |
terraform/api.tf |
HTTP API, JWT authorizer pointed at the pool's issuer, routes (one with authorization_scopes) |
terraform/lambda.tf, api/src/index.ts |
Lambda that reads the verified claims from requestContext.authorizer.jwt.claims |
web/src/auth/oidc.ts |
oidc-client-ts configuration: authority = issuer, code + PKCE, logout URL from discovery |
web/src/auth/cognito.ts |
Direct Cognito API calls for password + TOTP sign-in and TOTP enrolment |
scripts/payroll-export.ts |
The machine-to-machine client |
scripts/totp.ts |
Authenticator replacement for tests and terminals |
test/expenses.test.ts |
Integration test |
- The hosted UI lives at
http://localhost.localstack.cloud:4566/_aws/cognito-idp/login; the/oauth2/authorizeendpoint redirects there. The<domain>.auth.<region>.amazoncognito.comhostname does not exist locally. Because the SPA readsauthorization_endpointfrom discovery, nothing in the app depends on either. - The login page remembers the last visit in a cookie and first shows "Sign in as existing user". Pick Sign in as different user? to reach the form.
- The login page handles password and
NEW_PASSWORD_REQUIREDonly. A user with TOTP enabled cannot finish signing in there, which is why the SPA has its own password + TOTP form. On AWS the hosted UI would present the challenge itself. - LocalStack issues tokens from the authorization code grant without verifying the PKCE
code_verifier. The SPA still sends it, and AWS enforces it. - Confirmation, forgot-password and e-mail OTP codes are printed at
INFOlevel.lstk logshides them by default; uselstk logs -v. - Browser calls to LocalStack need the origin in
EXTRA_CORS_ALLOWED_ORIGINS; otherwise the response is403before Cognito sees the request.
cd terraform
terraform init
terraform apply -var localstack=false -var cognito_domain_prefix=<something-globally-unique>
cd .. && npm run configureThe issuer becomes https://cognito-idp.<region>.amazonaws.com/<pool id> and the SPA picks up the real hosted UI through the same discovery document. Remove the ALLOW_USER_PASSWORD_AUTH flow from the SPA client if you prefer SRP in production.
make destroy
make stopThis code is available under the Apache 2.0 license.