CognitoApi is an authentication and user management api based on the solid AWS Cognito service. It lets you build your applications without thinking about the authentication part.
A detailed documentation and a dedicated website about this project with a lot of material is located here: CognitoApi.
Real curl calls โ request payload and JSON response โ captured live against AWS Cognito:
-
๐ก๏ธ Security: The solution is very secure and uses MFA (Multi Factor Authentication) which adds an important security layer to your apps.
-
โ๏ธ AWS Cognito as a backend: The CognitoApi is based at its heart on the AWS Cognito service, known to be very secure and cost effective.
-
๐ฐ Cost: AWS Cognito is free for the first 50K users (or MAU: Monthly Active Users), so it's a good choice for bootstrapping your app without thinking about the cost.
-
๐ฅ User Management Lifecycle: The solution supports the needed users management life cycle from the creation till the deletion.
-
๐ค Fully automated: Nothing to do except deploying using Terraform.
-
๐ Cors support: The Cors is already supported, you can plug your front without any effort.
Before installing the CognitoApi, please ensure that you already installed:
-
๐๏ธ Terraform: a recent version is recommended, in order to deploy all the underlying AWS infrastructure.
-
๐ณ Docker: to build all lambdas and their layers in an automatic way.
-
๐ง AWS CLI: to perform some of the project bootstrapping actions like creating the S3 bucket and DynamoDB table for Terraform states and collaboration.
-
๐ Python 3 with a recent
boto3(pip install boto3): the passkey-enable Terraform step runs a small local script (helper_scripts/enable_passkeys.py). Set the interpreter via thepasskey-bootstrap-pythontfvar (defaults topython3). Only needed if you enable passkeys.
Warning First you need to configure at least one deployment environment, let's call it dev. Copy the template and fill in your own values โ the real file is gitignored, so your values never get committed:
cp terraform/environments/dev/terraform.tfvars.dev.example terraform/environments/dev/terraform.tfvars.devThen edit terraform/environments/dev/terraform.tfvars.dev.
And here is the explanation of the relevant parameters:
| Parameter | Description | Example |
|---|---|---|
| aws-region | The region where to deploy your authentication API | eu-west-1 |
| terraform-state-bucket | This S3 bucket will hold your terraform states files | my-tf-state-bucket |
| auth-api-dns-name | This is the dns to use for your API | auth.dev.example.com |
| auth-api-acm-certificate-name | This is the domain name for which the certificate should be issued | *.dev.example.com |
| route53-zone-name | The Route 53 hosted zone name (the domain) used for the DNS records of your API | dev.example.com |
| certificate-name-tag | The tag name that will be set for the ACM certificate used by your API | wildcard.dev.example.com |
| auth-api-name | The authentication API name | myco-auth-api |
| auth-api-description | The authentication API description | MyCo Auth API |
| auth-api-r53-zone-id | The route53 zone ID to use (corresponding to route53-zone-name) | Z1029992156D8O |
| auth-api-stage-name | The API gateway stage name to use | development |
| cognito-reply-to-email-address | The email that will be used when a user wants to answer your emails | hello@myapp.io |
| cognito-from-email-address | The email that will be used to send emails by AWS Cognito | hello@myapp.io |
| cognito-ses-email-arn | The ARN of the SES verified email identity to use | arn:aws:ses:eu-west-1:012345678901:identity/hello@myapp.io |
| from-email | Sender address used for the MFA reset recovery-code email | hello@myapp.io |
| email-mfa-reset-subject | Subject of the MFA reset recovery-code email | Reset your authenticator (MFA) |
| email-mfa-reset-message | Body of the MFA reset recovery-code email (must contain {code}) |
Your MFA reset code is {code}. ... |
The process of installation is as follows:
export AWS_PROFILE=MyAwsDevProfile
git clone https://github.com/TocConsulting/cognito-api.git
cd terraform
ENVIRONMENT=dev make test
ENVIRONMENT=dev make plan
ENVIRONMENT=dev make applyThe last commands will:
-
Set up your AWS profile by exporting it inside the terminal, please change the name of the profile MyAwsDevProfile to yours and set it also inside the file terraform/live/services/auth-microservice/provider.tf.
-
Test the terraform infrastructure files.
-
Perform a terraform plan to check if everything is okay and gives you an idea about all resources that will be deployed.
-
Perform a terraform apply to deploy all the needed infrastructure inside your AWS account.
Please refer to the Makefile help: 
ENVIRONMENT=dev make destroy # destroy EVERYTHING: the stack THEN its backend (state bucket + lock table)
ENVIRONMENT=dev make destroy-stack # destroy the stack only (keep the backend / state history)
ENVIRONMENT=dev make destroy-backend # remove the backend only: state S3 bucket + DynamoDB lock tableThe state bucket and lock table are created by the bootstrap outside Terraform (they hold the
state, so they can't manage themselves), so a plain terraform destroy can't remove them. make destroy handles that for you: it destroys the stack and then deletes the backend, leaving nothing
behind. Use destroy-stack when you want to keep the state history, and make clean to wipe local
generated files (.terraform, backend.tf, __pycache__, build zips, etc.).
The deployed infrastructure looks like this:
| Resource Type | Count | Purpose |
|---|---|---|
| API Gateway | 1 | To expose the authentication Rest API |
| Cognito User Pool | 1 | To hold all the users and manage their lifecycle |
| Lambdas | 27 | API handlers (incl. 6 passkey endpoints) + 3 custom-auth triggers (MFA reset) + the custom-message trigger |
| Lambdas layers | 2 | Shared code for lambdas (jsonschema, pyjwt) |
| S3 bucket | 1 | Terraform remote state (created during bootstrap) |
| DynamoDB table | 1 | For terraform infrastructure collaboration lock |
| ACM Certificate | 1 | For the Rest API |
| Route53 records | 2 | For the ACM verification and the API custom domain |
It's hard, because it depends on the context of everyone, but let's use these parameters:
-
๐ The deployment region is eu-west-1.
-
๐ฅ Let's assume 50K users and all of them perform a login (2 requests) every day on the app (the RefreshToken is valid for 24 hours, so the app can use it to refresh the user session), that means 24 requests, so in total: 26 requests per user per day, which means: 1.3 million requests for maintaining sessions on a daily basis. This also means that users are connected 24 hours per day all time (very unlikely to happen).
-
๐งฎ Let's assume that all backend lambdas uses 256MB of memory and for each call the average duration of each lambda is 1 second (which is excessive!). They will be called 1.3 million every day.
Based on all these assumptions, the authentication and the user management of your application for 50k users will costs you around: 150$ per month, this means 0.003$ per user and per month! Hard to find on the market a serious competitor at this level.
We did a lot of technical choices inside this project:
-
๐๏ธ The use of Terraform as the main IAC (Infrastructure As Code) is absolutely innocent :). The main reason of this choice instead of Cloudformation or any other IAC tool is the popularity and the simplicity. I've been using personally Terraform from almost the first release and I have been, in general, quite satisfied with it.
-
๐ MFAs are mandatory and I chose to activate the Google Authenticator MFA by default, mainly because there are no additional costs.
-
๐ฑ AWS Cognito does not offer a direct API to reset a lost TOTP authenticator under mandatory MFA. CognitoApi solves this without storing anything: no QR code or secret is ever persisted (the old S3 bucket is gone). A user who lost their authenticator recovers through a dedicated
CUSTOM_AUTHrecovery flow (/v1/mfa-reset/*) that requires both their password and a one-time code emailed by Cognito's ownCreateAuthChallengetrigger. Only after both proofs does Cognito issue a session to re-enroll a fresh TOTP; the recovery session is then immediately revoked. MFA stays mandatory and Cognito-enforced the whole time - the recovery factor lives outside the pool's MFA factors, so enrollment and normal login are never affected. No SMS and no extra cost. -
๐ The CognitoApi is using a Makefile to pilot all the infrastructure deployment, this Makefile is designed to handle multi environment deployment, to do so just create a configuration environment file named: environments/MyEnvName/terraform.tfvars.MyEnvName. If you want to handle the multi environments using Terraform workspaces, please share your version with us.
-
๐ No API key. CognitoApi does not use an API key. An API key is an identifier, not authentication: it travels in every request and is visible in any browser that calls the API, so it only ever gave a false sense of security. Endpoint protection is handled at the right layers instead - see Protecting against abuse & financial DDoS below.
-
๐ Password policy is: 14 characters minimum length that contains at least 1 number, at least 1 special character, at least 1 uppercase letter and at least 1 lowercase letter.
-
๐ก๏ธ Abuse / Financial-DDoS protection is not bundled (to keep the base deployment free of paid services), but it is strongly recommended for production - see the dedicated section below for the WAF + throttling approach.
-
โฑ๏ธ Access and ID Tokens are set to be valid for one hour, the Refresh Token is valid for 24 hours. If you want to modify these values, just set the target values for: id-token-validity, access-token-validity and refresh-token-validity variables.
-
๐ The Advanced security features of Cognito are disabled because of their cost.
CognitoApi has no API key. An API key is an identifier, not authentication - it rides in every request and is readable in any browser that calls the API, so it never actually protected anything. Real protection belongs at two different layers, and conflating them is what made the old API key misleading.
Every request that reaches a Lambda is billed (Lambda invocation + duration + API Gateway request). A public auth API exposes endpoints that, by design, must be callable before a user has any credential
login,forgot-password. An attacker can hammer those millions of times and run up your bill, even though they never authenticate. No API key or token stops this (the attacker is unauthenticated by definition), and an API key in the browser is trivially copied. This is an edge / volumetric problem, not an authorization problem.
The principle is to reject abusive traffic as early and as cheaply as possible - before it invokes a Lambda:
- AWS WAF on the API Gateway stage (the primary control). WAF runs before the Lambda integration, so
blocked requests cost no Lambda compute:
- Rate-based rules per source IP (e.g. block an IP exceeding N requests / 5 min) - caps each source's blast radius.
- CAPTCHA / Challenge action on the sensitive public endpoints (
login,forgot-password, registration) - a silent challenge that humans pass and bot floods fail, at the edge. - AWS Managed Rules: IP-reputation and anonymous-IP (Tor/VPN/proxy) lists.
- API Gateway throttling - per-method and per-stage rate + burst limits (no API key needed). Throttled
requests return
429without invoking Lambda, bounding the expensive part globally. - CloudFront in front of API Gateway - absorbs/blocks volume at the AWS edge and gives AWS Shield Standard (L3/L4) for free. Add Shield Advanced (paid) for DDoS cost-protection insurance.
These are paid/optional services, so they are not bundled in the base deployment - but they are the right answer and should be enabled before exposing CognitoApi publicly.
Where a caller does have a credential, authorize properly instead of relying on a shared secret:
- Already enforced:
GET /v1/userinfoandDELETE /v1/users/{id}are gated by a Cognito User Pools JWT authorizer (validated at API Gateway from the pool's tokens - free, unlimited, no per-user API keys, which are also capped at 10,000/account/region). Extend the same authorizer to the other signed-in endpoints (e.g.logout,change-password, passkeys) as you harden. - Admin actions (user create / delete) - gate these with a Cognito client-credentials token + an admin scope, whose secret lives only in your backend.
โ ๏ธ As shipped today: no API key, a Cognito JWT authorizer onuserinfo+delete-user, and the remaining endpoints open. Removing the key was deliberate - it was never real protection - but add WAF + throttling (and broaden the JWT/M2M authorizers above) before any public, production exposure.
Note The endpoints take no API key. Calls only need the inputs each endpoint documents (and, where shown, a user
Authorization: Bearertoken). Before exposing the API publicly, read Protecting against abuse & financial DDoS.
You can find a Postman collection here: postman/CognitoApi.postman_collection.json. You need to set the following variables inside Postman:
| Variable | Description |
|---|---|
| API_BASE_URL | which is the dns name to use to call your auth api |
| the email of the user you want to create | |
| PASSWORD | the password to use when you create a test user |
| VerificationType | must be set to SOFTWARE_TOKEN_MFA |
The TOTP inside Postman is generated for you automatically using the MFA_SECRET environment variable, which is set from the API call output, once the user has been confirmed.
Let's see how this API works:
- Create a new user: Use a POST method on the endpoint: v1/users with the payload:
{
"full_name": "Jane Doe",
"email": "user@example.com",
"mobile_phone_number": "+3301234567"
}And you will get an answer that looks like this:
{
"email": "user@example.com",
"user_id": "129514d4-b081-7004-e470-b6adacd32db4",
"status": "CREATED"
}After this call the new user will receive an email containing a temporary password, that they need to use with the endpoint: v1/users/{{USER_ID}}/confirm to confirm the creation.
- Confirm a new user: Use a POST method on the endpoint: v1/users/{{USER_ID}}/confirm with the payload:
{
"email": "user@example.com",
"temporary_password": "WpbyHbcIc#pNo3",
"new_password": "WpbyHbcIc#pNp9"
}And you will get an answer that looks like this:
{
"email": "user@example.com",
"user_id": "129514d4-b081-7004-e470-b6adacd32db4",
"qr_code_secret": "UB42J65BKO473DIOOXGWOQZYT7AZKAS7W3AAHVTVT5IPRV",
"otpauth_uri": "otpauth://totp/CognitoApi%3Auser%40example.com?secret=UB42J65BKO473DIOOXGWOQZYT7AZKAS7W3AAHVTVT5IPRV&issuer=CognitoApi",
"mfa_session": "AYABeHVf5KBad3fI-guCzukJTdY...",
"status": "PENDING_MFA"
}Nothing is stored server-side: your client renders the QR code from otpauth_uri (or qr_code_secret), and you pass mfa_session back to the next call.
- Confirm the MFA: Use a POST method on the endpoint: v1/users/{{USER_ID}}/confirm-mfa with the payload (the
mfa_sessioncomes from the previous response):
{
"email": "{{EMAIL}}",
"otp": "123456",
"mfa_session": "AYABeHVf5KBad3fI-guCzukJTdY..."
}And you will get an answer that looks like this:
{
"email": "user@example.com",
"user_id": "129514d4-b081-7004-e470-b6adacd32db4",
"mfa_status": "CONFIRMED"
}- For a forgotten password: Use a POST method on the endpoint: v1/forgot-password with the payload:
{
"email": "user@example.com"
}And you will get an answer that looks like this (the same generic response is returned whether or not the account exists, to avoid user enumeration):
{
"email": "user@example.com",
"status": "PASSWORD_FORGOT_CONFIRMATION_SENT"
}- To set a forgotten password: Use a POST method on the endpoint: /v1/users/{{USER_ID}}/confirm-password with the payload:
{
"email": "user@example.com",
"new_password": "#Y3KdGR9QKg_a9",
"verification_code": "304482"
}And you will get an answer that looks like this:
{
"email": "user@example.com",
"user_id": "129514d4-b081-7004-e470-b6adacd32db4",
"status": "NEW_PASSWORD_SET_SUCCESSFULLY"
}If a user loses their authenticator app, they can re-establish MFA without an admin and without resetting their password. The flow proves both the password and control of the email, then issues a brand-new authenticator. Nothing is stored server-side and MFA stays mandatory the whole time.
- Step 1 - start the reset: Use a POST method on the endpoint: v1/mfa-reset/start with the payload:
{
"email": "user@example.com"
}Cognito emails a one-time recovery code ("Reset your authenticator (MFA)") and returns an opaque session (the same generic response is returned whether or not the account exists, to avoid user enumeration):
{
"status": "MFA_RESET_CODE_SENT",
"session": "AYABeJKqmUy2EDG08Dw5N0wz..."
}- Step 2 - verify the code + password: Use a POST method on the endpoint: v1/mfa-reset/verify with the payload:
{
"email": "user@example.com",
"session": "AYABeJKqmUy2EDG08Dw5N0wz...",
"code": "865079",
"password": "WpbyHbcIc#pNp9"
}On success a brand-new authenticator secret is issued (render it as a QR from otpauth_uri), along with an access token for the final step:
{
"qr_code_secret": "KOGSXDSCFTF7D5QWXH4VCIKP...",
"otpauth_uri": "otpauth://totp/CognitoApi%3Auser%40example.com?secret=KOGSXDSCFTF7D5QWXH4VCIKP...&issuer=CognitoApi",
"access_token": "eyJraWQiOiJrM2ZpdkRvb3ls...",
"status": "NEW_TOTP_ISSUED"
}- Step 3 - confirm the new authenticator: Use a POST method on the endpoint: v1/mfa-reset/confirm with a code from the freshly scanned authenticator and the access token from step 2:
{
"access_token": "eyJraWQiOiJrM2ZpdkRvb3ls...",
"otp": "921618"
}The old authenticator is automatically replaced and the recovery session is revoked:
{
"status": "MFA_RESET_COMPLETE"
}- Perform the first step of the login process: Use a POST method on the endpoint: v1/login with the payload:
{
"email": "user@example.com",
"password": "WpbyHbcIc#pNp9"
}And you will get an answer that looks like this:
{
"email": "user@example.com",
"verification_session": "AYABeLHXhcXCnAA3E29UUMbVqKgAHQABAAdTZXJ2aWNlABBDb2duaXRvVXNlclBvb2xz",
"verification_type": "SOFTWARE_TOKEN_MFA"
}After this call, the user needs to perform the MFA verification step using the verification session.
- Perform the second step of the login process (MFA challenge): Use a POST method on the endpoint: v1/mfa-verify with the payload:
{
"email": "user@example.com",
"verification_type": "SOFTWARE_TOKEN_MFA",
"verification_session": "AYABeLHXhcXCnAA3E29UUMbVqKgAHQABAAdTZXJ2aWNlABBDb2duaXRvVXNlclBvb2xz",
"otp_code": "012345"
}And you will get an answer that looks like this:
{
"id_token": "eyJraWQiOiJpK0dwZFZLVUY1eG1ESml6Ukk2YTVWYTV6ZEtyXC8zeElyR",
"access_token": "eyJraWQiOiJudUFPSENpcStPZnk3enF5TjFBZERSSEpQcUtZS1EwS",
"refresh_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNB",
"expires_in": 3600
}At this moment, the user has been logged successfully.
- To get new tokens using your RefreshToken: Use a POST method on the endpoint: v1/refresh-token with the payload:
{
"email": "user@example.com",
"refresh_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ."
}And you will get an answer that looks like this:
{
"email": "user@example.com",
"id_token": "eyJraWQiOiJpK0dwZFZLVUY1eG1ESml6Ukk2YTVWYTV6ZEtyXC8zeElyR2owZk",
"access_token": "eyJraWQiOiJudUFPSENpcStPZnk3enF5TjFBZERSSEpQcUtZS1EwSU9mUGd",
"refresh_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVA",
"expires_in": 3600
}- To get information about the connected user: Use a GET method on the endpoint: v1/userinfo with the header
Authorization: Bearer {{ID_TOKEN}}and you will get an answer that looks like this:
{
"name": "Jane Doe",
"user_id": "129514d4-b081-7004-e470-b6adacd32db4",
"email": "user@example.com",
"phone_number": "+33601234567",
"groups": []
}- To logout: Use a POST method on the endpoint: v1/logout with the payload:
{
"email": "user@example.com",
"access_token": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoi"
}And you will get an answer that looks like this:
{
"user_status": "logout"
}| Endpoint | Method | Purpose |
|---|---|---|
| v1/users | POST | Create a new user |
| v1/users/{USER_ID} | DELETE | Delete a user |
| v1/users/{USER_ID}/confirm | POST | Confirm a new user |
| v1/users/{USER_ID}/confirm-mfa | POST | Confirm MFA setup |
| v1/resend-confirmation-code | POST | Resend the new-user confirmation code |
| v1/forgot-password | POST | Initiate self-service password reset (emails a code) |
| v1/users/{USER_ID}/confirm-password | POST | Complete the self-service password reset |
| v1/users/{USER_ID}/reset-password | POST | Admin-force a user's password reset |
| v1/users/{USER_ID}/change-password | POST | Change password while signed in (old + new) |
| v1/mfa-reset/start | POST | Start an MFA reset (emails a recovery code) |
| v1/mfa-reset/verify | POST | Verify code + password, issue a new authenticator |
| v1/mfa-reset/confirm | POST | Confirm the new authenticator, complete the reset |
| v1/login | POST | Initial login step |
| v1/mfa-verify | POST | Complete MFA verification |
| v1/refresh-token | POST | Get new tokens using refresh token |
| v1/userinfo | GET | Get authenticated user information |
| v1/logout | POST | Log out a user |
| v1/passkeys/register/start | POST | Begin passkey (WebAuthn) registration |
| v1/passkeys/register/complete | POST | Finish passkey registration |
| v1/passkeys/list | POST | List a user's registered passkeys |
| v1/passkeys/delete | POST | Delete one of a user's passkeys |
| v1/passkeys/login/start | POST | Begin passwordless passkey sign-in |
| v1/passkeys/login/complete | POST | Finish passwordless passkey sign-in |
CognitoApi supports passwordless, phishing-resistant passkeys (Touch ID / Windows Hello / phone). A user-verified passkey satisfies the mandatory-MFA requirement on its own, so sign-in needs no password and no TOTP - while MFA stays mandatory and Cognito-enforced.
- Register (while signed in):
POST /v1/passkeys/register/startโ browsernavigator.credentials.create()โPOST /v1/passkeys/register/complete. - Sign in (passwordless):
POST /v1/passkeys/login/startโ browsernavigator.credentials.get()โPOST /v1/passkeys/login/completeโ tokens.
A zero-build tester lives in demos/passkey/passkey-test.html. Two things are deployer-specific because of how WebAuthn works - passkeys are bound to a domain (your pool's RelyingPartyId), so you cannot test from localhost or a mismatched domain:
- Set
webauthn-relying-party-idin your tfvars to a domain you own (e.g.example.com). - Serve the page over HTTPS from a host under that domain (e.g.
app.example.com):
cd demos/passkey
brew install mkcert nss && mkcert -install # one-time (trusts a local CA)
echo "127.0.0.1 app.example.com" | sudo tee -a /etc/hosts
PASSKEY_HOST=app.example.com python3 serve_https.py # auto-creates the TLS cert
# open https://app.example.com:8443/passkey-test.htmlIn the page: fill your API base URL + email, click Login โ fill access_token, then Register passkey, then Sign in with passkey. The full passkey experience is also built into the React example app.
Want to see CognitoApi in action? Check out our integration example with a React frontend:
This example app demonstrates:
- ๐ Complete authentication flow integration
- ๐ Token refresh handling
- ๐ค User registration and confirmation process
- ๐ MFA implementation
- ๐ผ Session management
The demo application is a great starting point to understand how to integrate CognitoApi with your frontend and provides a practical reference for implementation.
# Clone the demo app
git clone https://github.com/TocConsulting/cognito-api-react-example-app.git
# Follow the setup instructions in the repo's READMEThe solution is designed to work in any AWS region where Cognito is available:
| Region Flag | Region Name | Region Code |
|---|---|---|
| ๐บ๐ธ | US East (N. Virginia) | us-east-1 |
| ๐บ๐ธ | US East (Ohio) | us-east-2 |
| ๐บ๐ธ | US West (N. California) | us-west-1 |
| ๐บ๐ธ | US West (Oregon) | us-west-2 |
| ๐จ๐ฆ | Canada (Central) | ca-central-1 |
| ๐ช๐บ | Europe (Frankfurt) | eu-central-1 |
| ๐ฌ๐ง | Europe (London) | eu-west-2 |
| ๐ฎ๐ช | Europe (Ireland) | eu-west-1 |
| ๐ซ๐ท | Europe (Paris) | eu-west-3 |
| ๐ธ๐ฌ | Asia Pacific (Singapore) | ap-southeast-1 |
| ๐ฆ๐บ | Asia Pacific (Sydney) | ap-southeast-2 |
| ๐ฏ๐ต | Asia Pacific (Tokyo) | ap-northeast-1 |














