Hardening Customer Identity: A Security Engineer’s Field Notes on Amazon Cognito
Based on hands-on work in AWS’s Cognito workshop, reviewed through a security-engineering lens.
Most Cognito tutorials are written for application developers: get a user pool up, wire in a login page, ship it. That’s not the perspective I care about. As a security engineer, my job starts where the “getting started” guide ends. I want to know what the default posture looks like, what an attacker sees when they poke at the auth endpoints, and which knobs actually change my risk exposure versus which ones are cosmetic.
This post walks through five parts of the AWS-hosted Cognito workshop (catalog.workshops.aws/wyld-pets-cognito): user pool provisioning, sign-in/authorisation configuration, WAF-based brute-force protection, threat protection (advanced security), and adaptive/risk based authentication and documents what I configured, what I tested, and why it matters from a security standpoint. Any names, emails, or secrets that appeared on screen have been replaced with placeholders like <email> / <password>.
Lab 1 — Provisioning the User Pool (the identity foundation)
Everything downstream token issuance, session risk scoring, WAF protection depends on how the pool is provisioned here. Get this wrong, and you’re retrofitting security controls later; some settings (sign-in identifiers, required attributes) cannot be changed after creation.
Step 1: Application type and identifiers
I chose Single-page application (SPA) as the application type. This matters for the security engineer because Cognito treats SPA clients as public clients; no client secret is generated, and the app is expected to use the Authorisation Code flow with PKCE rather than the implicit grant or a confidential client. If a developer later tries to bolt a client secret onto this client type and ship it in front-end JS, that’s an immediate finding: a “secret” that isn’t secret in a browser context is worse than no secret, because it creates a false sense of confidentiality.
Two decisions here have real security implications:
- Email as the sole sign-in identifier. This is generally the right call for a consumer app: usernames add an enumeration surface without much benefit, and email as username simplifies account recovery. The trade-off is that email-based sign-in makes user enumeration via the sign-up/forgot password flow more directly meaningful, so downstream you want generic error messages and rate limiting on those flows (see Lab 8).
- Self-registration enabled. Anyone on the internet can now create an account. This is expected for a public-facing pet-store portal, but it’s also the setting that turns your sign-up endpoint into a target for automated account creation, credential-stuffing seed accounts, and scraping. This is precisely why the workshop later layers WAF and threat protection on top: self-registration without abuse controls is an incomplete configuration, not a finished one.
A yellow banner on this screen is worth calling out for anyone building runbooks: “Options for sign-in identifiers and required attributes can’t be changed after the app has been created.” This is not just a UX inconvenience; it means these decisions belong in your pre-deployment security review, not in a post-incident remediation ticket.
Step 2: Redirect URI (return URL)
/oidc/callbackThe return URL is Cognito’s redirect-URI allowlist for the OAuth/OIDC authorisation code flow. From a security standpoint, this is one of the highest value fields on the entire form: redirect URIs must be an exact match, not a prefix or wildcard, or you reopen the door to open-redirect-based authorisation code theft. When I review a Cognito app client configuration, this list is one of the first things I check * wildcard or an overly broad path here is a finding, full stop.
Step 3: Resulting user pool — what an attacker (and an auditor) can see
Once created, the pool exposes two unauthenticated, publicly-fetchable endpoints that are worth knowing well:
- OIDC discovery document (
/.well-known/openid-configuration) advertises the issuer, authorisation/token/userinfo endpoints, and supported scopes/response types. - JWKS URL (
/.well-known/jwks.json) the public signing keys any resource server needs to validate Cognito-issued JWTs.
Neither of these needs to be treated as a secret; they’re meant to be public, but I do treat the User Pool ARN as sensitive-adjacent, because it’s the resource identifier used in IAM policy conditions. Any IAM policy scoping Cognito admin actions (cognito-idp:AdminInitiateAuth, cognito-idp:AdminCreateUser, etc.) should be scoped to this specific ARN rather than "Resource": "*".
Handoff practice: rather than hard-coding the User Pool ID, App Client ID, and domain into the frontend app’s config or source, I pushed these values into AWS Secrets Manager and had the application resolve them at runtime via an internal API call. This keeps pool identifiers out of source control history and gives you a single place to rotate values if the pool is ever recreated.
Lab 2 — Sign-in Configuration and API Authorisation (resource servers)
This lab is where the pool goes from “an identity store exists” to “authentication and authorisation are actually configured.” Two distinct concerns live here: how humans authenticate, and how APIs authorise.
Choice-based sign-in (USER_AUTH flow)
The USER_AUTH flow lets the client offer users a choice of first-factor: password, an emailed OTP, or a passkey. I enabled password + email OTP and left passkeys and SMS OTP off. From a security-engineer perspective:
- Enabling email OTP as a first-factor option gives the app a password-less path, which is good for reducing credential-reuse risk, but it also means your email account security becomes a de facto authentication factor worth calling out in the app’s own threat model.
- I deliberately did not enable SMS OTP as a first-factor choice. SMS is vulnerable to SIM-swap and SS7-interception attacks, and using it as a primary authentication choice (not just an MFA step) is a weaker posture than password or a proper WebAuthn/passkey factor. Passkeys weren’t enabled in this pass simply because the workshop app doesn’t yet register them; this is a gap I’d flag for a follow-up iteration, since passkeys are the strongest phishing-resistant option Cognito currently supports.
Three settings on this same overview page are worth flagging as findings, not just observations, in a real engagement:
I’m not saying every one of these needs to change; some are legitimate risk trade-offs for a demo app, but a security review should surface each one explicitly rather than silently accepting Cognito’s defaults.
Resource servers and custom OAuth scopes
Read ("Read access to API") and Write ("Write access to API")This is the API-authorisation half of the lab, and it’s where many teams under-invest. A resource server in Cognito represents a backend API, and custom scopes let you issue access tokens that are limited to specific operations rather than a blanket “authenticated user” token. Once created, Cognito renders these scopes in the access token as PetStore/Read and PetStore/Write and per AWS's own guidance, this scope format is what your resource-server-protected API should check against on every request rather than just verifying the token's signature and expiry.
The security value here is straightforward least-privilege: an app client used only for browsing the catalogue can be limited toPetStore/Read, while a client used for order management or admin functions gets PetStore/Write. If a token is leaked or a client is compromised, scope limits contain the blast radius: the attacker gets read access to the PetStore API, not write. This is also the foundation for machine-to-machine (M2M) authorisation: a backend service using the client-credentials grant authenticates with a client ID/secret and receives only the scopes explicitly granted to it, with no end-user context at all.
One identifier note worth flagging in review: the resource server identifier cannot be changed after creation (the console flags this explicitly). Treat it the same way you’d treat an API’s base path in a versioning scheme — get it right up front.
Lab 8 — Brute-Force Protection with AWS WAF
This is where the workshop moves from “identity is configured” to “identity is defended.” The goal: put a rate-based WAF rule in front of the authentication endpoint and prove with an actual attack simulation that it engages.
Building the protection pack (Web ACL)
I attached the Cognito user pool directly as the protected resource this is the mechanism that lets AWS WAF inspect and rate-limit traffic to Cognito’s own hosted authentication endpoints including InitiateAuthnot just to a downstream API Gateway or ALB sitting in front of your app. For a security engineer, this is the detail that makes WAF-for-Cognito actually useful: protecting only your application layer while leaving the identity provider's endpoints unprotected is a common gap.
I selected “You build it” rather than the AWS-recommended managed rule bundle. This is a legitimate cost/coverage trade-off worth documenting in any real design: the Recommended pack bundles rate limiting, bot control, and IP reputation lists for roughly 5x the cost of a hand-picked rule set. For a workshop (and for many early-stage production APIs), a custom, minimal rule set focused on the actual threat you’re modelling credential stuffing against the auth endpoint is defensible. In a mature environment handling real payment or PII data, I’d push back towards the managed bundle, specifically for the Amazon IP reputation list and bot control.
The rate-based rule
[Screenshot: “Add rules” rule-type picker — IP-based, Geo-based, Rate-based (selected), Custom]
I built a rate-based rule named RateLimitAuth: block any source that crosses 1,000 requests in a rolling 5-minute window. Per AWS's rate-based-rule documentation, this type of rule aggregates and counts requests according to the aggregation key you choose (by default, source IP) and applies the configured action here only to requests from an aggregation instance that is currently over the limit; everything else continues to be evaluated normally. It's a targeted control: it doesn't touch a legitimate user retrying a mistyped password twice, but it does cut off a scripted credential-stuffing run hitting the endpoint hundreds of times a minute.
A note for anyone reproducing this: 1,000 requests per 5 minutes is a workshop-scale threshold, deliberately low so the demo attack trips it quickly. For a production InitiateAuth-fronting rule, I'd size this against real traffic baselines (P99 legitimate retry rate per IP, NAT/CGNAT considerations for shared corporate or mobile-carrier IPs) rather than copying the workshop number directly.
Get Shravan B J’s stories in your inbox
Join Medium for free to get updates from this writer.
I also configured a second custom rule RedirectSignup, that matches requests where the URI path exactly equals /signup and responds with a 302 redirect (via a custom response) back to the managed-login domain instead of letting that path be hit directly:
The intent here is to funnel all account-creation traffic through the audited, rate-limited, and (later) risk-scored managed login UI rather than allowing a scripted client to hit whatever backend /signup route it can find directly, closing off a parallel, less-monitored path to the same sensitive operation.
Naming, logging, and enabling the pack
CognitoProtection, Logging destination type: Amazon CloudWatch LogsI named the protection pack CognitoProtection and pointed its logging destination at Amazon CloudWatch Logs. This is not optional in my book: a WAF rule with no log export is a control you can't audit, tune, or use for incident response. Every block, count, and allow decision needs to land somewhere queryable.
Testing it: two auth attempts, two very different outcomes
First, a legitimate baseline call using the AWS CLI against the pool’s InitiateAuth API, confirming the app client and flow work end-to-end:
aws cognito-idp initiate-auth \
--client-id <app-client-id> \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME='<email>',PASSWORD='<password>'Then, a scripted brute-force simulation: a loop firing repeated InitiateAuth calls with a fake username/password pair, using the raw cognito-idp HTTPS API directly rather than the AWS CLI wrapper:
set +H
IDP_URL="https://cognito-idp.us-east-1.amazonaws.com/"
for i in $(seq 1 120); do
curl -s -o /dev/null -w "Request $i: HTTP %{http_code}\n" \
-X POST "$IDP_URL" \
-H "Content-Type: application/x-amz-json-1.1" \
-H "X-Amz-Target: AWSCognitoIdentityProviderService.InitiateAuth" \
-d "{\"ClientId\":\"<app-client-id>\",\"AuthFlow\":\"USER_PASSWORD_AUTH\",\"AuthParameters\":{\"USERNAME\":\"fake_user_$i@example.com\",\"PASSWORD\":\"<password>\"}}"
doneThe important thing to read correctly here: HTTP 400 on every request is the expected, unremarkable result at this stage; it means Cognito is rejecting the credentials themselves (bad username/password combo), not that WAF has done anything yet. 120 requests is well under the 1,000-request/5-minute threshold, so the rate-based rule hasn’t engaged; the endpoint is simply doing its job of authenticating (and rejecting) each attempt. This is a useful reminder for anyone testing WAF rules: a 400 on an unauthenticated brute-force script tells you the identity provider is functioning, not that the protection pack is functioning. You need to actually cross the threshold, and then check the WAF dashboard, not the HTTP status codes, to validate the rule.
Confirming the rule actually fired
RateLimitAuth ruleOnce traffic volume against the auth endpoint pushed past the configured threshold, the dashboard is unambiguous: of 368 total requests in the window, 240 were blocked by RateLimitAuth and only 128 passed through to the default action. This is the artifact I'd attach to a security sign-off: not the rule configuration screenshot, but the dashboard proving the rule engaged under load exactly as designed. As a habit, I'd rather validate every preventive control this way (trigger it, then check the telemetry) than trust that a saved configuration behaves as intended.
Lab 9 — Threat Protection (Advanced Security / Plus Plan)
WAF handles volume-based abuse well, but it has no concept of “is this password on a breach list” or “does this sign-in look like account takeover based on behavioural signals.” That’s Cognito’s own threat protection feature set, which requires the Plus feature plan.
Per AWS’s documentation, the Plus plan adds security features that log and analyse user context at runtime device, location, request data, and password signals and can automatically block or require additional verification for risky attempts, with logs exportable to S3, Data Firehose, or CloudWatch Logs for downstream analysis. This is the tier where Cognito stops being just a token issuer and starts acting like a lightweight identity-focused SOC control.
Baseline: everything off
Switching plans doesn’t enable anything by itself; it just unlocks the settings. Out of the box, both standard (password-based/hosted-UI) and custom (Lambda-driven challenge) authentication flows have no enforcement configured. This distinction matters: standard auth is the username/password and hosted-UI path; custom auth is anything built on DefineAuthChallenge/CreateAuthChallenge/VerifyAuthChallengeResponse Lambda triggers (e.g., a bespoke passwordless flow). AWS's own security guidance has specifically called out that custom-auth flows were historically a blind spot for threat detection, and that this coverage gap has been closing as the feature matures.
Turning it on for standard authentication
I set standard authentication to Full-function, meaning Cognito doesn’t just log risk signals; it actively takes preventive action based on them, while leaving custom authentication at No enforcement for this pass. That’s a deliberate, and debatable, scoping decision: the workshop app’s primary attack surface (password sign-in) gets active protection immediately, while the custom-challenge path is left for a follow-up phase once its risk model is understood. In a real engagement, I would not leave this at “no enforcement” indefinitely; at minimum, I’d move it to Audit-only so Cognito starts building a risk-signal baseline for that flow (which costs nothing in false blocks) before ever going Full-function on it.
Compromised-credential detection
This feature checks user-submitted credentials against Cognito’s compromised-credential dataset — effectively a “have these creds shown up in a known breach” check — and I configured it to trigger on sign-in and password-change events, with a Block sign-in response (Cognito blocks the session until the user sets a new password, rather than silently allowing it). I intentionally left sign-up unchecked in event detection for this pass, which is worth flagging as a gap rather than a deliberate strength: checking credentials at account creation is one of the cheapest ways to stop an attacker from seeding a batch of accounts with already-leaked password lists, and I’d recommend enabling it before this configuration goes anywhere near production.
Lab 10 — Adaptive Authentication and Risk-Based Response
This is the capstone: instead of static rules, Cognito scores each authentication attempt for risk and reacts differently depending on the score.
Configuring the risk-response matrix
I configured a graduated response: low risk is allowed outright, medium risk requires MFA as a step-up challenge, and high risk is blocked. This tiered model is the right shape for adaptive auth — a blanket “block anything suspicious” policy generates too many false positives and support tickets, while “MFA everything” adds friction to the 95%+ of sign-ins that are entirely legitimate. Graduated response lets the risk engine absorb ambiguity in the middle tier instead of forcing a binary decision.
I left IP address exceptions empty. In a real deployment, this is where you’d put your office/VPN egress ranges on the always-allow list (so pen-testers and internal QA don’t get auto-blocked mid-engagement) and known-malicious ranges on the always-block list, but I’d caution against over-populating the allowlist, since every excepted IP is a blind spot the risk engine can’t see into.
Watching the risk engine work: CloudWatch metrics
SignUp operation flagged CompromisedCredentialRisk at HighSignIn operation flagged AccountTakeoverRisk at High, and SignUp flagged CompromisedCredentialRisk at HighCognito emits per-operation, per-risk-classification CloudWatch metrics (AccountTakeoverRisk, CompromisedCredentialRisk) scoped to the user pool. This is exactly the kind of telemetry a SOC wants to alarm on — a spike in High-classified SignIn events for a given pool is a leading indicator worth paging on, well before it shows up as a support ticket or a fraud chargeback.
Seeing the block from the user’s side
This is the end-user-visible result of the High-risk → Block policy. Note the deliberately generic error text: Cognito doesn’t tell the caller why it was blocked (compromised password vs. impossible-travel vs. new device + new IP), which is correct behaviour: a more specific error would itself leak information useful to an attacker probing the defences.
The forensic trail
This is the artifact I’d actually walk a SOC analyst through in an incident review. Read chronologically: a successful password sign-in and a failed retry from one consistent location, a successful OTP-based sign-in from a second (still plausible) location and then a sign-in attempt from a third location, on a different continent, in the same short window, that Cognito’s risk engine classified as High risk / Account takeover and blocked outright. This is the textbook “impossible travel” signal, and it’s exactly what adaptive authentication is designed to catch without a human having to notice the pattern manually. The device fingerprint (browser/OS string) and IP address captured alongside each event are what turn this from “an event happened” into “here’s a timeline I can hand to IR.”
Security Engineer’s Checklist (what I’d carry into a real deployment)
Pulling the threads together, here’s the checklist I’d apply if I were reviewing (or building) a Cognito-backed identity layer for a production application, based directly on the gaps and decisions surfaced across these labs:
- Confirm app client type matches the trust boundary (SPA/public client → no secret, PKCE required; server-side/M2M → confidential client with a rotated secret in Secrets Manager)
- Redirect URIs are an exact match, no wildcards
- Sign-in identifier and required-attribute choices are reviewed before pool creation (they’re immutable after)
- Self-registration is paired with abuse controls (WAF rate limiting + threat protection) from day one, not added later
- MFA is at minimum optional, ideally required for privileged scopes/roles
- SMS is avoided as a primary auth or recovery factor where a stronger alternative (passkey, authenticator OTP) is viable
- Resource-server custom scopes are used to enforce least privilege on access tokens, not just “any authenticated user”
- A rate-based WAF rule protects the Cognito-hosted authentication endpoints directly, not just downstream APIs, with logging to CloudWatch/S3
- Threat protection is enabled for both standard and custom auth flows (Audit-only at minimum, Full-function where validated)
- Compromised credential detection covers sign-up, not just sign-in and password change
- Adaptive authentication uses a graduated response (Allow / Step-up MFA / Block) rather than a binary rule
- CloudWatch risk-classification metrics are wired into alarms/SIEM, not just left as browsable dashboards
- Identifiers (pool ID, client ID, domain) are pulled from Secrets Manager at runtime, never hard-coded into frontend source
References
- Amazon Cognito — Threat protection (developer guide)
- Amazon Cognito — Plus plan features
- Amazon Cognito — Scopes, M2M, and resource servers
- AWS Security Blog — Adding threat detection to custom authentication flow with Amazon Cognito advanced security features
- AWS WAF — Using rate-based rule statements
- AWS WAF — Applying rate limiting to requests
- Workshop source: Wyld Pets — Amazon Cognito Workshop
All credentials, tokens, and account identifiers shown in this post are placeholders or have been redacted from the original workshop screenshots.









