個人開発のAIアプリでは、ユーザーに メアド入力やSNS連携を強いると初回離脱率が跳ね上がります。一方、サーバーAPIに認証なしでアクセスさせると、無料枠を踏み台にされ、LLMのコストが青天井になります。
恋愛メッセージ分析アプリ Relora では、ユーザー体験を維持したまま無料枠を守るため、次の構成を採っています。
- インストール後すぐ使える: ログイン画面ゼロ
- デバイス内でランダム認証情報を生成 → Keychainに保存
- サインアップは API Gateway 経由でサーバー側 Cognito Admin API を叩く
- IPごとにサインアップ回数をDynamoDBで制限(無限アカウント生成対策)
- Cognito の AccessToken で API Gateway の JWT Authorizer を通過
本記事は、この「ログイン画面なし匿名認証」を Cognito User Pools + API Gateway + Lambda で実装する具体的な方法を解説します。
この記事で分かること
- Cognito の AdminCreateUser をクライアントに公開しない正しい設計
- iOS の Keychain で匿名クレデンシャルを安全に保管する方法
- API Gateway の JWT Authorizer に Cognito アクセストークンを通す設定
- DynamoDB の条件付き更新で IPレート制限を実現する方法
- 「サインアップAPIを開ける」とは何が危険か、何を守ればいいか
採用しなかった選択肢
| 方式 | 不採用の理由 |
|---|---|
| Cognito Identity Pool(フェデレーション匿名) | アクセスキー直渡しになり、API Gateway の JWT Authorizer と相性が悪い。スロットリングを突破するためだけに使うには重い |
クライアントから SignUp API を直接叩く |
パブリックなサインアップが開いたままになり、bot で大量アカウント生成される |
| Sign in with Apple 必須 | 起動直後に「Appleでサインイン」を出すと初回離脱が増える |
| 認証なし + API Key のみ | アプリのバイナリから API Key を抜けば誰でも叩ける |
最終形は 「クライアントは匿名、しかし Cognito ユーザーは存在する」 という構成です。サインアップは Lambda 内で AdminCreateUser + AdminSetUserPassword を実行し、クライアントに User Pool への直接書き込み権限を一切渡しません。
全体構成
[iOS App]
│ (1) Keychainからクレデンシャル取得 or 生成(u_xxxx + ランダム24byte password)
│
├──→ POST /v1/signup ← 初回のみ。IP制限あり
│ (API Gateway + Lambda)
│ └─ AdminCreateUser + AdminSetUserPassword
│
├──→ Cognito InitiateAuth ← 直叩き
│ (USER_PASSWORD_AUTH)
│ └─ AccessToken 取得
│
└──→ POST /v1/analyze ← Authorization: Bearer <AccessToken>
(API Gateway JWT Authorizer)
ポイントは2つ。
-
SignUpを直接公開せず、Lambda経由でAdminCreateUserを呼ぶことで、サインアップAPIの呼び出しレートをサーバー側で完全制御できる -
InitiateAuthだけはクライアントから直接叩く。これは Cognito 自身がスロットリングする上、ユーザー名/パスワードを知らないと通らないので比較的安全
クライアント側: Keychainで匿名クレデンシャルを管理
SecRandomCopyBytes で 16バイトのユーザー名(hex化)と 24バイトのパスワード(base64化)を生成します。Keychainの kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly で デバイス外への流出と、初回ロック解除前のアクセス を防ぎます。
nonisolated private static func getOrCreateCredentials() -> (username: String, password: String) {
let service = "com.m-naoki-m.Relora"
let account = "cognito_credentials_v2"
// Keychain読み出し
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
if SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data,
let creds = try? JSONDecoder().decode(StoredCredentials.self, from: data) {
return (creds.username, creds.password)
}
// 暗号的に安全なランダム認証情報を生成
var usernameBytes = [UInt8](repeating: 0, count: 16)
_ = SecRandomCopyBytes(kSecRandomDefault, usernameBytes.count, &usernameBytes)
let username = "u_\(usernameBytes.map { String(format: "%02x", $0) }.joined().prefix(16))"
var passwordBytes = [UInt8](repeating: 0, count: 24)
_ = SecRandomCopyBytes(kSecRandomDefault, passwordBytes.count, &passwordBytes)
// Cognito の Password Policy(大小文字+数字+記号)を満たす形に整形
let password = "Rp\(Data(passwordBytes).base64EncodedString().prefix(20))1!"
let newCreds = StoredCredentials(username: String(username), password: password)
if let data = try? JSONEncoder().encode(newCreds) {
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
SecItemAdd(addQuery as CFDictionary, nil)
}
return (String(username), password)
}
ここで使っている SecRandomCopyBytes は CSPRNG(暗号学的乱数)です。arc4random() や Int.random(in:) ではなく必ずこれを使います。
Cognito InitiateAuth は HTTP 直叩き
AWS SDK for iOS(amplify)を入れるとビルド時間が一気に重くなるので、URLSession で素のHTTPを叩きます。
let cognitoURL = URL(string: "https://cognito-idp.ap-northeast-1.amazonaws.com/")!
var request = URLRequest(url: cognitoURL)
request.httpMethod = "POST"
request.setValue("application/x-amz-json-1.1", forHTTPHeaderField: "Content-Type")
request.setValue("AWSCognitoIdentityProviderService.InitiateAuth",
forHTTPHeaderField: "X-Amz-Target")
let payload: [String: Any] = [
"AuthFlow": "USER_PASSWORD_AUTH",
"ClientId": clientId,
"AuthParameters": [
"USERNAME": username,
"PASSWORD": password,
],
]
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, response) = try await URLSession.shared.data(for: request)
返ってくる AuthenticationResult.AccessToken を以後の API Gateway 呼び出しで Authorization: Bearer ... ヘッダーに乗せます。
「ユーザーがまだ存在しない」を判定して自動サインアップ
初回起動時は当然ながら Cognito にユーザーが存在しないので、UserNotFoundException か NotAuthorizedException が返ってきます。それを契機に /v1/signup を叩いてからリトライします。
if httpStatus == 400 {
let errorBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
let errorType = errorBody?["__type"] as? String ?? ""
if errorType.contains("UserNotFoundException")
|| errorType.contains("NotAuthorizedException") {
// サインアップしてリトライ
try await signUp(username: username, password: password)
let (data2, _) = try await URLSession.shared.data(for: request)
// 以降、AccessTokenを取り出す
}
}
サーバー側: Lambda で AdminCreateUser
Lambdaハンドラの /v1/signup は 認証不要 で公開しますが、内部で IP レート制限をかけたうえで AdminCreateUser + AdminSetUserPassword を実行します。
def _handle_signup(event, context):
body = json.loads(event.get("body", "{}"))
username = body.get("username", "")
password = body.get("password", "")
if not username or not password:
return _error(400, "username and password required")
if len(username) > 50 or len(password) > 100:
return _error(400, "Invalid credentials")
# IPレート制限(同一IPから1日100回まで)
source_ip = event.get("requestContext", {}).get("http", {}).get("sourceIp", "unknown")
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
ip_key = {"PK": f"SIGNUP_IP#{source_ip}", "SK": f"DATE#{today}"}
ttl_value = int(time.time()) + 86400 * 2
try:
usage_table.update_item(
Key=ip_key,
UpdateExpression="SET #cnt = if_not_exists(#cnt, :zero) + :one, #ttl = :ttl",
ConditionExpression="attribute_not_exists(#cnt) OR #cnt < :limit",
ExpressionAttributeNames={"#cnt": "signup_count", "#ttl": "ttl"},
ExpressionAttributeValues={
":one": 1, ":zero": 0,
":ttl": ttl_value,
":limit": SIGNUP_IP_DAILY_LIMIT,
},
)
except usage_table.meta.client.exceptions.ConditionalCheckFailedException:
return _error(429, "Too many signup attempts. Try again tomorrow.")
cognito = _get_cognito_client()
try:
cognito.admin_create_user(
UserPoolId=user_pool_id,
Username=username,
MessageAction="SUPPRESS",
TemporaryPassword=password,
)
cognito.admin_set_user_password(
UserPoolId=user_pool_id,
Username=username,
Password=password,
Permanent=True,
)
except cognito.exceptions.UsernameExistsException:
return {"statusCode": 409,
"body": json.dumps({"status": "already_exists"})}
return {"statusCode": 200, "body": json.dumps({"status": "created"})}
ポイントは次の通り。
-
MessageAction="SUPPRESS": 確認メールを送らない(メアドそもそも持っていない) -
Permanent=True: 仮パスワード状態をスキップして即ログイン可能に -
UsernameExistsExceptionは 409 で返す: クライアントは「すでにある」を成功扱いして InitiateAuth に進める - Lambda の IAM Role には
cognito-idp:AdminCreateUser/AdminSetUserPasswordのみを付与
IP レート制限の DynamoDB テーブル設計
PK: SIGNUP_IP#<source_ip>
SK: DATE#<YYYY-MM-DD>
signup_count: N
ttl: N
update_item の ConditionExpression で 「カウントが上限未満ならインクリメント、上限以上なら例外」 をアトミックに処理します。Lambda の同時実行で競合してもカウントが上限を超えることはありません。TTLを2日にしておけば古いレコードは勝手に消えます。
API Gateway 側: JWT Authorizer
CDK で API Gateway の HTTP API を作り、Cognito User Pool を JWT Authorizer として登録します。
const authorizer = new HttpJwtAuthorizer(
"CognitoAuthorizer",
`https://cognito-idp.${region}.amazonaws.com/${userPool.userPoolId}`,
{ jwtAudience: [userPoolClient.userPoolClientId] }
);
httpApi.addRoutes({
path: "/v1/analyze",
methods: [HttpMethod.POST],
integration: new HttpLambdaIntegration("AnalyzeIntegration", analyzeLambda),
authorizer: authorizer,
});
// /v1/signup は認証なし
httpApi.addRoutes({
path: "/v1/signup",
methods: [HttpMethod.POST],
integration: new HttpLambdaIntegration("SignupIntegration", analyzeLambda),
});
これで /v1/analyze は AccessToken なしのリクエストを API Gateway の段階で拒否 します。Lambda にリクエストすら届かないので、Bedrock に1円も払わずに済みます。
Lambda 側ではトークンの sub クレームをユーザーIDとして使います。
claims = event.get("requestContext", {}) \
.get("authorizer", {}).get("jwt", {}).get("claims", {})
user_id = claims.get("sub")
if not user_id:
return _error(401, "Unauthorized")
想定される攻撃と防御の対応表
| 攻撃 | 防御 |
|---|---|
| アプリのバイナリから API URL を抜いて勝手に叩く | API Gateway の JWT Authorizer で 401 |
| Cognito SignUp を直接叩いて大量アカウント作成 | クライアントには SignUp 権限を渡さず、Lambda の /v1/signup 経由のみ |
/v1/signup を bot で叩いて大量アカウント作成 |
DynamoDB の IPごとカウンタで1日100回まで |
| 既存アカウントの AccessToken を盗む | Keychain AfterFirstUnlockThisDeviceOnly で取り出し制限 |
| 同一アカウントを複数端末で使い回して無料枠超過 | サーバー側でユーザー単位の日次レート制限(別記事 Q4 参照) |
| InitiateAuth ブルートフォース | Cognito 標準のスロットリング |
完全ではありませんが、コストが青天井になる経路はすべてサーバー側で塞いでいます。
制約と注意点
- アカウント復旧不可: メアド連携がないので、端末紛失や OS 再インストールで匿名アカウントは失われます。サブスクリプションは StoreKit 側に残るので復元購入で復活しますが、分析履歴はクラウドに保持していないため復旧できません(Reloraは履歴をデバイス内 SwiftData に保存する設計)
- 同一ユーザーの判定が端末単位: 同じ人が iPhone と iPad で別アカウントになる。マルチデバイス連携をしたい場合は将来的に Sign in with Apple へのアップグレード経路を用意する必要あり
- Cognito の料金: MAU 課金。匿名でも MAU としてカウントされるので、bot による大量サインアップは料金にも効く(→ IPレート制限が効いてくる)
まとめ
- 匿名サインアップは「
SignUpをクライアントに開放しない」が大原則 - クライアントは Keychain + InitiateAuth のみ、書き込みは Lambda 経由
- IPレート制限は DynamoDB の条件付き更新でアトミックに実装できる
- API Gateway の JWT Authorizer で「Lambda にすら届かない」防壁を作る
- ユーザー体験ゼロ摩擦と無料枠保護は両立できる
関連記事:
- ローカルOCR + クラウドLLMのハイブリッドアーキテクチャ
- Amazon Bedrock Converse APIでマルチモデル切替
- [Q6: StoreKit 2 + サーバー側JWS検証で安全な課金システム]
- スクショ→AI分析アプリの全体設計
Relora(App Store): https://apps.apple.com/app/relora/id6762029713