# AWS Cognito Custom Authentication Flow Implementation Guide: OTP/Passwordless with the CUSTOM_AUTH Challenge, Store the PIN Safely with PBKDF2

> An implementation guide for implementing OTP, passwordless, and LINE authentication with Cognito's CUSTOM_AUTH challenge (the Define/Create/Verify Lambda triggers), and storing a card PIN safely with PBKDF2-HMAC (high iterations, CSPRNG salt, constant-time comparison). Explained with real code, down to the post-confirmation hook and log masking.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: AWS, Cognito, 認証, セキュリティ, Python
- URL: https://tomodahinata.com/en/blog/aws-cognito-custom-authentication-pin-pbkdf2-passwordless-guide
- Category: Authentication & authorization
- Pillar guide: https://tomodahinata.com/en/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase

## Key points

- CUSTOM_AUTH is the control of the authentication flow, PBKDF2 is the storage of secrets — separate them as different problems at different layers
- Divide responsibilities with the 3 triggers Define/Create/Verify; Define handles the cutoff of attempt counts, and Verify the constant-time comparison
- If you just want MFA, the standard built-in MFA is enough, and think of CUSTOM_AUTH as for custom challenges not in the built-in
- Hash the PIN one-way with PBKDF2-HMAC, bundle the CSPRNG salt and iteration count, and verify with hmac.compare_digest in constant time
- A 4-digit PIN has a small key space, so rather than hash strength, the rate-limiting of attempt counts is the first line of defense

---

"I want to log in with a username and password" — if that's all, Cognito's standard flow ends it. Configure it and it works.

But real-world requirements usually spill over from there. **Don't want to make users use a password (passwordless). Want them to enter with an OTP (one-time code) delivered by email or SMS. Want to link with a LINE account. Want fast authentication with a 4-digit PIN on an in-store terminal.** These don't ride directly on Cognito's built-in "username + password" model.

What you use then is **CUSTOM_AUTH (custom authentication challenge).** It's a mechanism where you write yourself, in Cognito's 3 prepared Lambda triggers (**DefineAuthChallenge / CreateAuthChallenge / VerifyAuthChallengeResponse**), "what question to pose, how to pose it, and how to verify it."

And there's one more separate problem you must not conflate. **How to store the "secret itself" like a PIN or a password.** CUSTOM_AUTH is the control of the authentication flow, not the storage of secrets. Secret storage is a separate-layer matter of **one-way hashing with PBKDF2-HMAC.**

This article explains, with real code, the design judgments I implemented in a multi-tenant payment platform for environment / carbon-credit / local-currency (4 faces: customer, merchant, admin, in-store terminal; AWS serverless; 0 double charges in production) for **the Cognito custom authentication flow** and **safe storage of a card PIN.**

> **The rule of this article**: the sources of the spec, parameter names, and recommended values are the **AWS Cognito official documentation and the OWASP official documentation (as of June 2026).** Since the trigger event shapes and iteration counts can be revised, always confirm the latest in the [official documentation at the end](#references-official-documentation) before shipping to production. **Always hash and mask secrets (PINs, OTPs, tokens), and never emit them to logs.** The code is shaped for real-operation assumptions, but secrets presuppose environment variables / Secrets Manager (hardcoding strictly forbidden).

This article narrows to "the design and implementation of the custom authentication flow" and "secret storage." For the division of roles, let me place 3 related articles first.

- **Which authentication method to assemble / the whole design of complex requirements**: [Cognito design for complex authentication requirements](/blog/aws-cognito-complex-authentication-design)
- **Enterprise SSO with SAML/OIDC**: [SAML/OIDC enterprise SSO](/blog/aws-cognito-saml-oidc-enterprise-sso)
- **How the API side verifies the issued token (JWT)**: [Cognito JWT (RS256) verification](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide)

**Design, SSO, and token verification go there. This article concentrates on the custom authentication flow and secret storage.**

---

## 0. The Mental Model: "Define the Challenge Yourself" with 3 Triggers

First, make a map in your head. Against requirements where username + password isn't enough (OTP, passwordless, LINE linkage, PIN), Cognito gives room to "define the challenge (the question and the answer) yourself." That's the 3 Lambda triggers.

| Trigger | Role (in one phrase) | What it sees on input | What it decides on output |
| --- | --- | --- | --- |
| **DefineAuthChallenge** | Flow control: what challenge to pose next | `event.request.session` (the results so far) | `challengeName` / `issueTokens` / `failAuthentication` |
| **CreateAuthChallenge** | The challenge content: generate and send the OTP | `event.request.challengeName` / `session` | `publicChallengeParameters` / `privateChallengeParameters` / `challengeMetadata` |
| **VerifyAuthChallengeResponse** | The answer's verification: is the answer correct | `privateChallengeParameters` / `challengeAnswer` | `answerCorrect` |

The official organization is clear, so let me quote it. **Define manages "the order of challenges," Create manages "the content of the challenge," and Verify matches "the known correct answer against the answer the app sent."** These 3 connect like a chain to make "an authentication mechanism completely per your own design" (official "user-pool-lambda-challenge").

And, **independent of this**, a separate problem is "secret storage."

- **CUSTOM_AUTH**: the flow of "posing, sending, and matching" a one-time-only OTP. The OTP is discarded once used.
- **PBKDF2 hash**: turning a "long-held secret" like a PIN or a password into an **irreversible one-way hash** and placing it in the DB. Verification is "hash with the same procedure and compare in constant time."

Not mixing these 2 is the starting point of design. The OTP is held in CUSTOM_AUTH's `privateChallengeParameters` only briefly, and the PIN is stored long-term in the DB as a PBKDF2 hash. The layers differ (SRP: separation of concerns).

---

## 1. When Standard Authentication Is Enough, When CUSTOM_AUTH Is Needed

Custom authentication is powerful, but **you also need to write a login UI and the multi-round challenge-response logic on the app side, and managed login can't handle it** (the official docs state plainly). That is, it's a judgment to pay "the developer's additional cost." Choose it carelessly and it becomes debt. First judge whether the requirement is met by the standard.

| Requirement | Standard flow (USER_SRP_AUTH, etc.) | CUSTOM_AUTH |
| --- | --- | --- |
| Username + password | ✅ as-is | Unneeded |
| MFA (SMS / TOTP / email OTP) | ✅ Built-in MFA is enough | Unneeded (use standard MFA) |
| Passwordless (sign in with OTP only) | ❌ | ✅ The right fit |
| LINE / custom-IdP OTP, confirmation code | ❌ | ✅ The right fit |
| Make a 4-digit PIN, security question, or CAPTCHA "part of authentication" | ❌ | ✅ The right fit |
| Custom verification with a hardware key, biometrics, or external API | ❌ | ✅ The right fit |

Judge with KISS and YAGNI. **If you just want MFA, Cognito's built-in MFA (`SMS_MFA` / `SOFTWARE_TOKEN_MFA` / `EMAIL_OTP`) is enough.** You needn't deliberately write 3 triggers. Do SRP auth with `USER_SRP_AUTH` and, for users with MFA configured, Cognito automatically follows with `SMS_MFA`, etc.

CUSTOM_AUTH is truly needed only **when you want to make "a question not in Cognito's built-in" part of authentication.** Want to send the OTP via your own channel (LINE, your own email foundation), incorporate the PIN into the auth flow, or treat the result of an external biometric API as "the correct answer" — only with these requirements does it pay off.

> In my payment platform, channels were separated into in-store terminal, merchant, and customer, and I needed to **absorb diverse sign-ins like LINE / email OTP with one mechanism.** Here is CUSTOM_AUTH's turn. Conversely, I kept the normal email + password entrance on the standard flow, confining complexity to just the faces that needed it (localizing complexity).

---

## 2. The Whole Flow: From InitiateAuth to IssueTokens

CUSTOM_AUTH starts with `InitiateAuth` (or `AdminInitiateAuth`), repeats `RespondToAuthChallenge`, and finally **succeeds the moment DefineAuthChallenge returns `issueTokens: true` and tokens are issued.** A challenge response can also become "the next challenge," going back and forth as needed (official).

For passwordless, to let users enter with "PIN only" or "OTP only," don't interpose SRP's password verification; enter the CUSTOM_AUTH challenge from the start. Per the official docs, **if you don't want to start from password verification, set `AuthParameters`' `CHALLENGE_NAME` to `CUSTOM_CHALLENGE` and call `InitiateAuth`.**

```jsonc
// パスワードレス開始：いきなり CUSTOM_CHALLENGE から
{
  "AuthFlow": "CUSTOM_AUTH",
  "ClientId": "1example23456789",
  "AuthParameters": {
    "USERNAME": "testuser",
    "CHALLENGE_NAME": "CUSTOM_CHALLENGE"
    // SECRET_HASH はアプリクライアントにシークレットがある場合に付与
  }
}
```

On the other hand, to make it multi-factor where you "verify the password, then also require an OTP," go through SRP first. The order the official docs show is this ("SRP authentication in custom challenge flows").

1. The app calls `InitiateAuth` with `CHALLENGE_NAME: SRP_A`・`SRP_A`・`USERNAME`.
2. Cognito calls DefineAuthChallenge with `session` containing `challengeName: SRP_A` / `challengeResult: true`.
3. The Lambda returns `challengeName: PASSWORD_VERIFIER`, `issueTokens: false`, `failAuthentication: false`.
4. When password verification succeeds, Cognito calls again with a `session` of `challengeName: PASSWORD_VERIFIER` / `challengeResult: true`.
5. The Lambda returns `challengeName: CUSTOM_CHALLENGE` and enters the custom challenge.
6. The challenge loop repeats until everything is answered.

What you should remember here is that **`session` is an array, with the challenges posed/resolved so far stacked chronologically.** `session[0]` is the first challenge. DefineAuthChallenge looks at this array every time to decide "what to pose next." This is the heart of flow control.

---

## 3. DefineAuthChallenge: Controlling the Flow

The first thing you write is Define. **"Look at the session so far and return the next challengeName. If everything is done, issueTokens=true; if you want to fail, failAuthentication=true"** — the responsibility is just this (SRP).

The event shape the official docs define (excerpt) is as follows.

- `event.request.session`: an array of `ChallengeResult`. Each element is `challengeName` / `challengeResult` (`true` on success) / `challengeMetadata` (your own name for the `CUSTOM_CHALLENGE`).
- `event.request.userNotFound`: with `PreventUserExistenceErrors` set to `ENABLED`, it becomes `true` for a non-existent user.
- `event.response.challengeName` / `issueTokens` / `failAuthentication`: what to do next.

Here, **the handling of `userNotFound` is quietly important.** The official docs recommend "keep the same behavior and same delay whether the user exists or not, so the caller can't detect the difference." Return `failAuthentication=true` early for a non-existent user and the user's existence can be **inferred from the difference in response time or behavior** (a user-enumeration attack).

```python
"""DefineAuthChallenge: 認証フローのオーケストレーション。
ここでは『パスワードレスで OTP 1回』のシンプル構成を制御する。
- まだ何も解いていなければ -> CUSTOM_CHALLENGE を出す
- OTP に正解したら -> トークン発行
- 規定回数失敗したら -> 認証を打ち切る
"""
MAX_CHALLENGE_ATTEMPTS = 3  # OTP の試行上限（Verify と整合させる）


def handler(event, context):
    req = event["request"]
    res = event["response"]
    session = req.get("session") or []

    # 1) まだチャレンジを出していない -> 最初の CUSTOM_CHALLENGE
    if len(session) == 0:
        res["issueTokens"] = False
        res["failAuthentication"] = False
        res["challengeName"] = "CUSTOM_CHALLENGE"
        return event

    last = session[-1]

    # 2) 直近の CUSTOM_CHALLENGE に正解 -> トークン発行
    if last["challengeName"] == "CUSTOM_CHALLENGE" and last["challengeResult"] is True:
        res["issueTokens"] = True
        res["failAuthentication"] = False
        return event

    # 3) 失敗回数が上限に達した -> 打ち切り（OTP 総当たりの抑止）
    failed = sum(
        1 for c in session
        if c["challengeName"] == "CUSTOM_CHALLENGE" and c["challengeResult"] is False
    )
    if failed >= MAX_CHALLENGE_ATTEMPTS:
        res["issueTokens"] = False
        res["failAuthentication"] = True
        return event

    # 4) まだ猶予がある -> もう一度 CUSTOM_CHALLENGE（同じ OTP を再入力させる）
    res["issueTokens"] = False
    res["failAuthentication"] = False
    res["challengeName"] = "CUSTOM_CHALLENGE"
    return event
```

The official Node.js sample was a form of "branch by looking at `session.length` and each element's `challengeName`." I **judge by "the latest result" and "the aggregate of failure counts" without directly depending on the length.** The reason is ETC (Easy To Change): when you add 1 challenge in the future, shifting all the magic numbers like `length === 3` is fragile. Write by meaning (did it succeed last, how many times did it fail) and even if the number of stages changes, the impact stays localized.

> **Important**: before issuing tokens, the official docs clearly state "always check `challengeName` and confirm it's the expected value." In the branch that returns `issueTokens=true`, **always confirm the latest challenge is truly the one you intended.** Be sloppy here and it can become a hole where tokens are issued on an unexpected challenge result.

---

## 4. CreateAuthChallenge: Generate the OTP and Send It Safely

When Define specifies `CUSTOM_CHALLENGE`, Cognito calls CreateAuthChallenge. This is **the content of the challenge** — the place to generate the OTP, send it to the user, and **hide the correct answer in `privateChallengeParameters`** to entrust to Cognito.

The event shape the official docs define (excerpt):

- `event.request.challengeName`: the next challenge name (the value Define decided).
- `event.response.publicChallengeParameters`: info **OK to show the client** (a hint like "We sent the OTP to your email").
- `event.response.privateChallengeParameters`: info **only the Verify trigger uses.** In the official words, "`publicChallengeParameters` is the 'question' presented to the user, and `privateChallengeParameters` contains the 'correct answer' to that question."
- `event.response.challengeMetadata`: your own name to attach to this custom challenge.

### 4.1 Make the OTP with a CSPRNG

The first thing not to get wrong in OTP generation is the random source. **Don't use the `random` module for cryptographic purposes** (predictable). In Python, use `secrets`.

```python
"""CreateAuthChallenge: OTP を生成し、メール/LINE で送り、
正解を privateChallengeParameters に隠す。
- 乱数は secrets（CSPRNG）で生成
- 平文 OTP は publicChallengeParameters に絶対入れない
- OTP・宛先はログに出さない
"""
import json
import os
import secrets
import time

OTP_TTL_SECONDS = 300  # 有効期限 5 分


def generate_otp() -> str:
    # 000000〜999999 を CSPRNG で。先頭ゼロ込みの 6 桁固定。
    return f"{secrets.randbelow(1_000_000):06d}"


def handler(event, context):
    req = event["request"]
    res = event["response"]

    if req["challengeName"] != "CUSTOM_CHALLENGE":
        return event  # 自分の担当外は素通し（防御的）

    # 既存セッションに OTP があれば再送せず使い回す（同一チャレンジの再表示時）
    existing = _existing_otp(req.get("session") or [])
    otp = existing or generate_otp()

    if existing is None:
        _deliver_otp(req["userAttributes"], otp)  # 送信は副作用として分離（SRP）

    # クライアントに見せてよい情報だけ public に
    res["publicChallengeParameters"] = {
        "deliveryMedium": "EMAIL",          # 「メールに送った」程度の UI ヒント
        "maskedDestination": _mask_email(req["userAttributes"].get("email", "")),
    }
    # 正解と期限は private に（Verify だけが読む）
    res["privateChallengeParameters"] = {
        "otp": otp,
        "expiresAt": str(int(time.time()) + OTP_TTL_SECONDS),
    }
    res["challengeMetadata"] = "EMAIL_OTP"
    return event


def _existing_otp(session: list) -> str | None:
    # challengeMetadata を手がかりに、直近の自分のチャレンジを判定して引き継ぐ
    for c in reversed(session):
        if c.get("challengeMetadata") == "EMAIL_OTP":
            # 注意: session の要素から private は読めない設計なので、
            # 再送制御は外部ストア(後述)で行うのが本筋。ここは概念図。
            return None
    return None
```

There are 2 design cautions here.

1. **Don't put the plaintext OTP in `publicChallengeParameters`.** `public` goes to the client = it can reach the attacker's hands too. Put the OTP only in `private`. In `public`, place only a hint like "sent to email" or "the destination's tail is ***@example.com."
2. **`privateChallengeParameters` is handy but don't over-trust it.** `private` reaches Verify, but because it's tied to Cognito's challenge round-trips, **"state" like the OTP's expiry and resend count is more robust to TTL-manage in an external store like DynamoDB** (the later pitfall).

### 4.2 Separate Delivery Per Channel (SRP)

The "sending" of the OTP is a separate concern from generation and verification. Email (SES), SMS (SNS), and LINE (Messaging API) just differ by channel, so make only the delivery swappable.

```python
"""OTP 配送: チャネルを抽象化。生成・検証から切り離す（ETC）。"""
import boto3

_ses = boto3.client("ses")


def _deliver_otp(user_attrs: dict, otp: str) -> None:
    email = user_attrs.get("email")
    phone = user_attrs.get("phone_number")
    # LINE 連携ユーザーなら line_user_id を見て Messaging API に切替、等
    if email:
        _send_email_otp(email, otp)
    elif phone:
        _send_sms_otp(phone, otp)
    # ※ ここで otp も email/phone も print/log しない（PII + 秘密）


def _send_email_otp(to: str, otp: str) -> None:
    _ses.send_email(
        Source="no-reply@example.com",
        Destination={"ToAddresses": [to]},
        Message={
            "Subject": {"Data": "ログイン用ワンタイムコード"},
            "Body": {"Text": {"Data": f"認証コード: {otp}（5分間有効）"}},
        },
    )
```

> **IAM with least privilege**. What CreateAuthChallenge's execution role needs is just **delivery permissions** like `ses:SendEmail`. Don't give permissions to the payment DB or keys. In my platform, I **separated IAM into least privilege per stack**, designing so the auth Lambda can't touch payment resources (the principle of least privilege).

---

## 5. VerifyAuthChallengeResponse: Verify the Answer in Constant Time

When the user answers the question Create posed, Cognito calls Verify. **Compare `event.request.privateChallengeParameters` (the correct answer) and `event.request.challengeAnswer` (the user's answer), and return `true`/`false` in `event.response.answerCorrect`** — that's all (official).

The official Node.js sample was a **plain `===` comparison** of `privateChallengeParameters.answer === challengeAnswer`. As a sample this is correct, but **using a normal `==` / `===` for comparing a secret leaves room for a timing attack.** Because string comparison "cuts off at the first differing byte," the longer the matching prefix, the longer the processing takes, and from that difference the correct answer can be narrowed down 1 character at a time.

Do the OTP match with a **constant-time comparison.** In Python it's `hmac.compare_digest`.

```python
"""VerifyAuthChallengeResponse: OTP を定数時間で照合し、期限・形式も検査する。
- hmac.compare_digest で一致比較（タイミング攻撃対策）
- 期限切れ・形式不正は即 false
- OTP も回答もログに出さない
"""
import hmac
import time


def handler(event, context):
    req = event["request"]
    res = event["response"]

    private = req.get("privateChallengeParameters") or {}
    expected = private.get("otp", "")
    answer = (req.get("challengeAnswer") or "").strip()

    res["answerCorrect"] = _verify_otp(expected, answer, private.get("expiresAt"))
    return event


def _verify_otp(expected: str, answer: str, expires_at: str | None) -> bool:
    # 形式チェック（6桁数字）。境界で弾く＝総当たり面積を減らす
    if len(answer) != 6 or not answer.isdigit():
        return False
    # 有効期限（Create で privateChallengeParameters に入れた値）
    if expires_at is not None and int(time.time()) > int(expires_at):
        return False
    if not expected:
        return False
    # 定数時間比較。長さ差で早期 return しないよう encode してから比較
    return hmac.compare_digest(expected.encode("utf-8"), answer.encode("utf-8"))
```

There are 3 points.

1. **Constant-time comparison**: `hmac.compare_digest` compares in constant time regardless of input length. Always use this for OTP, PIN, and token matching.
2. **Format validation at the boundary**: if not 6 digits, immediately `false`. Don't pass external input (`challengeAnswer`) through; validate it at the boundary (input validation).
3. **Expiry**: an expired OTP is invalid. Judge the expiry by the value Create put in `privateChallengeParameters.expiresAt`.

> **The attempt-count cap isn't closed by Verify alone.** Verify only returns the pass/fail of a single match. "Cut off after 3 failures" holds by §3's Define counting the failure count within `session` and returning `failAuthentication=true`. **The final lockout judgment is Define, the on-the-spot match is Verify** — don't mistake the responsibilities.

---

## 6. Sign-Up / Post-Confirmation Hook: The post-confirmation Trigger

Separate from custom authentication's "sign-in," there's initialization you want to do **after sign-up confirmation.** For example, "create a payment-profile row for a confirmed user" or "prepare an initial PIN-registration record." What handles this is the **PostConfirmation trigger.**

Per the official docs, PostConfirmation fires on **`ConfirmSignUp` / `AdminConfirmSignUp` / `ConfirmForgotPassword`** (and on sign-up / password-reset confirmation in managed login). `event.request` contains the confirmed user's `userAttributes` and `clientMetadata`, and **there's no additional info to return in the response** (`"response": {}`).

```python
"""PostConfirmation: サインアップ確認後の初期化。
- triggerSource でサインアップ確認とパスワードリセット確認を区別
- 副作用は冪等に（同じ確認が2回来ても二重作成しない）
- 例外で握りつぶさず、ユーザー体験を壊さない範囲で記録
"""
import boto3

_ddb = boto3.resource("dynamodb")
_profiles = _ddb.Table("payment_user_profiles")


def handler(event, context):
    source = event.get("triggerSource")  # 例: PostConfirmation_ConfirmSignUp
    attrs = event["request"]["userAttributes"]
    sub = attrs["sub"]  # Cognito の不変ユーザーID（メールではなく sub をキーに）

    if source == "PostConfirmation_ConfirmSignUp":
        _ensure_profile(sub)  # 冪等な作成
    elif source == "PostConfirmation_ConfirmForgotPassword":
        _mark_password_reset(sub)  # リセット完了を記録（PIN 再登録を促す等）

    return event  # response は空のまま返す


def _ensure_profile(sub: str) -> None:
    # 条件付き書き込みで「既にあれば作らない」= 冪等（再実行・重複イベント耐性）
    try:
        _profiles.put_item(
            Item={"user_sub": sub, "status": "active"},
            ConditionExpression="attribute_not_exists(user_sub)",
        )
    except _ddb.meta.client.exceptions.ConditionalCheckFailedException:
        pass  # 既存ならスキップ（正常系）
```

There are 2 design cruxes.

1. **Make it idempotent**: a confirmation event can be resent / duplicated. Guarantee "don't create if it exists" with `ConditionExpression="attribute_not_exists(...)"` to prevent double creation (reliability). The same feel as protecting "0 double-creation / double-charge" in the payment platform.
2. **Key on `sub`**: an email address can change. Use Cognito's immutable ID `sub` (`userAttributes.sub`) as the primary key, and treat email merely as a contact point.

> **When to use it vs pre-signup**: if you want to control "whether to permit registration / whether to auto-confirm," PreSignUp; if it's initialization **after** confirmation, PostConfirmation. In this article's theme (the custom authentication flow), PostConfirmation, which arranges user-peripheral data after confirmation, is central. Note, post-confirmation should not block the user experience with heavy synchronous processing — keep external I/O minimal and record failures appropriately.

---

## 7. Secret Storage: Hash a Card PIN One-Way with PBKDF2-HMAC

From here is the "separate problem." The OTP is discarded on the spot, but **a PIN or a password is a secret held long-term.** **Storing it in plaintext is out of the question.** Storing it with reversible encryption is also dangerous (leak the key and all of it reverts to plaintext). **The right answer is one-way hashing** — use `hashlib.pbkdf2_hmac`, and at verification time "hash with the same procedure and compare in constant time."

### 7.1 Choosing the Algorithm: PBKDF2, or Argon2id

First, the selection. The OWASP password-storage cheat sheet makes the priority order clear.

| Algorithm | OWASP's positioning | Recommended parameters (official values) | When to choose |
| --- | --- | --- | --- |
| **Argon2id** | First recommendation | `m=19456 (19 MiB), t=2, p=1` | When you can use memory-hard, the first candidate |
| **scrypt** | Runner-up | — | An environment where Argon2id isn't usable |
| **bcrypt** | For legacy | Input cap **72 bytes** | Only maintaining an existing system |
| **PBKDF2** | **Top priority if FIPS-140 is needed** | The table below (per HMAC) | When NIST/FIPS compliance is a requirement |

OWASP says of PBKDF2 that "**it's recommended by NIST and has FIPS-140-validated implementations, so prioritize it when those are requirements.**" That is, **for pure strength alone it's Argon2id, but if regulation/compliance (FIPS-140 compliance) is a requirement, PBKDF2 is the right answer.**

Payment / finance can require FIPS-140 compliance, and there's also the advantage of completing with the Python standard library (`hashlib`) alone without adding dependencies. In my platform, I **adopted PBKDF2-HMAC.** This is a selection of "because it best fits the requirements (standard library, the implementation's maturity, compliance)," not "because it's the strongest" (YAGNI / cost efficiency: don't add unneeded dependencies).

### 7.2 Iteration Count: OWASP's Official Values

PBKDF2's strength is decided by the iteration count. OWASP's current recommendation differs per the HMAC's hash function.

| PBKDF2's HMAC | OWASP recommended iterations | Note |
| --- | --- | --- |
| **PBKDF2-HMAC-SHA256** | **600,000** | A general recommendation |
| **PBKDF2-HMAC-SHA512** | **220,000** | — |
| PBKDF2-HMAC-SHA1 | 1,400,000 | **Legacy only** |

> When I built my platform, I adopted **100,000 iterations.** This was a value decided at the time in a trade-off with the requirements and terminal performance. **The iteration count presupposes raising it year by year**, and for a new implementation, base it on the above OWASP current value (600,000 for SHA256) and decide by measuring the hash time on your own production hardware. Bundle the iteration count, salt length, and algorithm **into the storage format** so you can raise them in stages later (ETC).

### 7.3 Implementation: A Salt-Bundled, Versioned Storage Format

A PIN hash **stores "the salt, iteration count, and algorithm" together with the hash.** Otherwise, when you later change parameters, you can't verify existing hashes.

```python
"""PIN/パスワードを PBKDF2-HMAC で一方向ハッシュ化して保管する。
- ソルトは 32 バイト CSPRNG（os.urandom / secrets）で毎回生成
- ソルト・反復回数・アルゴリズムを文字列に同梱（後から引き上げ可能に）
- 検証は hmac.compare_digest で定数時間比較
- 平文 PIN は受け取った関数の外に出さない/ログにも出さない
"""
import base64
import hashlib
import hmac
import secrets

# 新規はこの値を基準に（OWASP: SHA256 は 600_000）。本番ハードで計測して調整。
PBKDF2_ALGO = "sha256"
PBKDF2_ITERATIONS = 600_000
SALT_BYTES = 32          # 32 バイト CSPRNG ソルト
DERIVED_KEY_LEN = 32     # 出力長


def hash_pin(pin: str) -> str:
    """平文 PIN -> 保存用文字列。形式: pbkdf2$<algo>$<iter>$<salt_b64>$<hash_b64>"""
    salt = secrets.token_bytes(SALT_BYTES)            # 毎回ユニーク（CSPRNG）
    dk = hashlib.pbkdf2_hmac(
        PBKDF2_ALGO, pin.encode("utf-8"), salt, PBKDF2_ITERATIONS, DERIVED_KEY_LEN
    )
    return "$".join([
        "pbkdf2",
        PBKDF2_ALGO,
        str(PBKDF2_ITERATIONS),
        base64.b64encode(salt).decode("ascii"),
        base64.b64encode(dk).decode("ascii"),
    ])


def verify_pin(pin: str, stored: str) -> bool:
    """平文 PIN と保存済みハッシュを定数時間で照合。"""
    try:
        scheme, algo, iter_s, salt_b64, hash_b64 = stored.split("$")
    except ValueError:
        return False
    if scheme != "pbkdf2":
        return False

    salt = base64.b64decode(salt_b64)
    expected = base64.b64decode(hash_b64)
    # 保存時と同じパラメータで再導出（だからこそ同梱が必須）
    candidate = hashlib.pbkdf2_hmac(
        algo, pin.encode("utf-8"), salt, int(iter_s), len(expected)
    )
    # 定数時間比較。True/False の分岐を比較結果のみに依存させる
    return hmac.compare_digest(candidate, expected)
```

Let me organize the points this implementation upholds.

- **The salt is 32 bytes CSPRNG, unique per password.** Generate it with `secrets.token_bytes(32)` and store it together with the hash. Without a salt, the same PIN becomes the same hash, and a rainbow table or "bulk identification of people using the same PIN" holds. **The salt isn't a secret, so bundling it is fine** — its role is "make the same input a different hash."
- **Bundle the iteration count and algorithm.** Make it the form `pbkdf2$sha256$600000$...$...` and, when you later migrate from SHA256/600k to a stronger setting, you can do a **re-hash on the spot** at login success for a staged migration (ETC).
- **Constant-time comparison.** The end of verification is always `hmac.compare_digest`. Don't use `==` on strings.

> **The reality of a 4-digit PIN**: a 4-digit PIN has only 10,000 combinations in its key space, and no matter how strong you make the hash, it's weak to an offline brute force. **So a PIN is protected not by "hash strength" alone but by "rate-limiting of attempt counts" and "binding to the terminal / context."** A PIN hash is the last fortress that delays the damage on a leak; the first line of defense is "narrow the attempt count on the server side." Misunderstand this and you put in strong PBKDF2 and feel safe.

---

## 8. Security Pitfalls (Must-Read Checklist)

That an implementation works and that it's safe are different. Let me enumerate the pitfalls you actually tend to step on in CUSTOM_AUTH and secret storage.

- **Narrow the OTP attempt count**: count failures in Define's `session` and `failAuthentication=true` at 3–5. Further, also limit the "OTP-issue rate (the resend interval to the same user)" in an external store. Without this, you become a target for OTP brute force / send spam.
- **Attach an OTP expiry**: put `expiresAt` in `privateChallengeParameters` in Create and always check it in Verify. An OTP without an expiry is the same as a "key usable forever."
- **Don't put a plaintext OTP / PIN / token in `publicChallengeParameters`**: `public` goes to the client = reaches the attacker too. The correct answer always only in `private`.
- **Compare in constant time**: the matching of OTP, PIN, token, and signature is `hmac.compare_digest`. `==` / `===` is the entrance to a timing attack.
- **A salt is mandatory, CSPRNG, unique every time**: an unsalted hash can be bulk-reverse-looked-up with a rainbow table. The salt needn't be secret, but it must differ per hash.
- **Don't store in plaintext or with reversible encryption**: a PIN/password is one-way hash only. Reversible encryption is a single point of failure that turns all of it into plaintext on a key leak.
- **Don't emit PIN / OTP / token to logs**: secrets tend to leak in exception stack traces and debug logs. **Mask email and phone in logs, and never output PINs or tokens.**
- **Set `PreventUserExistenceErrors` to ENABLED**: don't change behavior / delay on `userNotFound`. It prevents a user-enumeration attack.
- **IAM with least privilege per stack**: the auth Lambda's execution role gets only the bare minimum like delivery permissions. Don't give permissions to the payment DB or key management.
- **Don't allow OTP reuse**: invalidate an OTP once it succeeds (or is cut off by failure). Don't let the same code be reused.

### Log Masking: Never Emit Secrets

Finally, let me crush "logs," where accidents are most frequent. **Always mask a value holding a secret before log output.**

```python
"""ログ用マスキング: PII はマスク、秘密は出力しない。
構造化ログにはユーザーID(sub)・チャレンジ名・成否などメタデータだけ残す。
"""
import re

_SECRET_KEYS = {"otp", "pin", "password", "token", "challengeAnswer",
                "privateChallengeParameters", "secret_hash"}


def mask_email(value: str) -> str:
    # a***@example.com の形に
    if "@" not in value:
        return "***"
    local, _, domain = value.partition("@")
    head = local[:1] if local else ""
    return f"{head}***@{domain}"


def mask_phone(value: str) -> str:
    return re.sub(r"\d(?=\d{2})", "*", value)  # 末尾2桁だけ残す


def safe_log_payload(event_request: dict) -> dict:
    """ログに出してよい最小メタデータだけを抽出する。"""
    attrs = event_request.get("userAttributes", {})
    return {
        "sub": attrs.get("sub"),                       # 不変ID（PIIではない識別子）
        "email": mask_email(attrs.get("email", "")),   # マスク
        "phone": mask_phone(attrs.get("phone_number", "")),
        "challengeName": event_request.get("challengeName"),
        # ↑ otp / pin / token / challengeAnswer は意図的に含めない
    }
```

The rule is simple. **Don't put secret keys (OTP, PIN, token, answer, private parameters) into the log payload from the start.** Design it as "don't put them in in the first place" (an allowlist scheme), not "forget to mask them," and accidents don't structurally occur.

---

## 9. Summary: A Cheat Sheet

Finally, a quick reference for when you're lost.

**When to use CUSTOM_AUTH**

- Just want MFA → **the standard built-in MFA** (CUSTOM_AUTH unneeded).
- Passwordless, OTP via your own channel, PIN incorporated into auth, custom verification with an external API → **CUSTOM_AUTH.**

**The responsibilities of the 3 triggers**

- **Define** = flow control. Look at `session` and return the next `challengeName` / `issueTokens` / `failAuthentication`. **The attempt-count cutoff is here.**
- **Create** = the content. Generate and send the OTP with a CSPRNG, put the correct answer in `privateChallengeParameters`, only a hint in `publicChallengeParameters`, and the name in `challengeMetadata`.
- **Verify** = the match. Compare `challengeAnswer` and `privateChallengeParameters` with **`hmac.compare_digest` (constant time)** and return `answerCorrect`. Check the expiry and format too.
- **PostConfirmation** = initialization after confirmation. Distinguish confirmation/reset with `triggerSource`, and key on `sub` **idempotently.**

**Secret storage**

- Hash a PIN/password **one-way with PBKDF2-HMAC** (top priority if FIPS-140 is a requirement, Argon2id for pure strength).
- **Base the iteration count on OWASP's current value** (PBKDF2-HMAC-SHA256 = 600,000) and measure on your own production hardware.
- **A 32-byte CSPRNG salt, unique every time, bundled with the hash.** Bundle the algorithm and iteration count too so you can raise them later.
- **Verify with a constant-time comparison.** Plaintext storage, reversible encryption, and log output are forbidden.
- A 4-digit PIN has a small key space, so **rather than hash strength, the rate-limiting of attempt counts is the first line of defense.**

CUSTOM_AUTH gives you "the freedom to design the authentication flow yourself," but that freedom comes paired with **the responsibility to guarantee security yourself.** OTP attempt limiting, constant-time comparison, salt, log masking, least-privilege IAM — miss these basics and the flexibility directly becomes a hole.

In an environment-domain serverless payment platform, I **realized diverse sign-ins like LINE / email OTP with Cognito's custom authentication (sign-up / post-confirmation hook / CUSTOM_AUTH challenge)**, and **stored a card PIN safely with PBKDF2-HMAC (CSPRNG salt, constant-time comparison).** I validated input at the boundary, separated IAM into least privilege per stack, and masked email and phone in logs while never emitting PINs or tokens — with this design I protected **0 double charges in production.**

**"How to land your authentication requirements (passwordless, OTP, PIN, external-IdP linkage) into Cognito, and how to store secrets safely" — I accompany you end-to-end, from requirements-organizing through implementation and operation.** Authentication is a domain directly connected to trust if it goes wrong. From the design stage, feel free to consult me.

For the before-and-after relationship, the selection / whole design of authentication methods is in [Cognito design for complex authentication requirements](/blog/aws-cognito-complex-authentication-design), SSO with an external IdP in [SAML/OIDC enterprise SSO](/blog/aws-cognito-saml-oidc-enterprise-sso), and verification of the issued token in [Cognito JWT (RS256) verification](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide). Read them together.

---

### References (Official Documentation)

- [Custom authentication challenge Lambda triggers (AWS Cognito)](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html) — the whole CUSTOM_AUTH flow, SRP linkage, the round-trips of InitiateAuth/RespondToAuthChallenge
- [Define Auth challenge Lambda trigger (AWS Cognito)](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-define-auth-challenge.html) — `session` / `challengeName` / `issueTokens` / `failAuthentication`
- [Create Auth challenge Lambda trigger (AWS Cognito)](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-create-auth-challenge.html) — `publicChallengeParameters` / `privateChallengeParameters` / `challengeMetadata`
- [Verify Auth challenge response Lambda trigger (AWS Cognito)](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-verify-auth-challenge-response.html) — `challengeAnswer` / `answerCorrect`
- [Post confirmation Lambda trigger (AWS Cognito)](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-post-confirmation.html) — `triggerSource` / `ConfirmSignUp` / `ConfirmForgotPassword`
- [Password Storage Cheat Sheet (OWASP)](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) — PBKDF2 iterations, salt, algorithm priority order, FIPS-140
