Rork アプリの Sign in with Apple を、サーバー検証とアカウント削除まで本気で実装するガイド
Rork で作ったアプリに Sign in with Apple をクライアントだけで載せて満足してしまうと、審査で確実に詰まります。ID トークンの公開鍵検証から nonce の扱い、必須となったアカウント削除フロー、Apple への revoke 通知までを、現場で動かしている構成のまま全部書きます。
「Sign in with Apple をボタンだけ置いてリリースしたら、Apple からのレビュー結果がリジェクトだった」— Rork で個人開発をしていると、この経験は意外なほど高頻度で発生します。私も最初の 1 本目はこれで 2 回連続リジェクトされ、3 回目でようやく通したクチです。レビュアーから返ってくるメッセージは妙に淡白で、何が足りないのか自分で全部組み立てて推測するしかありません。
このガイドは、そのときに「最初からこれが分かっていれば良かった」と感じたことを、Rork のコード生成と相性の良い形で全部まとめ直したものです。具体的には、expo-apple-authentication でクライアント側を組み立てつつ、Cloudflare Workers で ID トークンの JWS 検証・nonce 検証・アカウント削除(revoke_token 通知込み)まで一気通貫で実装します。Sign in with Google や Sign in with Twitter など他の認証を入れている場合の「Apple ID も併設しないとリジェクトされる」というルールにも触れます。
なぜ「ボタンを置いただけ」では絶対に通らないのか
Sign in with Apple は単なる OAuth プロバイダではありません。Apple は App Store ガイドラインの 4.8 と 5.1.1(v) で、Sign in with Apple をどう実装するべきかをかなり細かく規定しています。私が痛い目を見たポイントを先に並べておきます。
ガイドライン 4.8: アプリで第三者の認証サービス(Google・Facebook・Twitter 等)を使っている場合、Sign in with Apple を「同等の選択肢として」必ず提供しなければなりません。「同等」には UI 上の見せ方も含まれます。Apple のボタンを下の方に小さく置く、はリジェクト対象です
Apple Developer Documentation の地味な脚注: ID トークン (identityToken) を取得したら、サーバー側で Apple の公開鍵を使って JWS 署名を検証することが「必須」と書かれています。クライアントが受け取った email を信用してそのまま DB に書き込む実装は、この時点で本番品質ではありません
逆に言えば、この 3 点を満たして実装すれば、Sign in with Apple 起因のリジェクトはほぼゼロになります。本記事はその 3 点を全部「動くコード」で示します。
層 4: 削除フロー層: ユーザーがアプリ内で「アカウント削除」を押したとき、ローカルの DB から該当ユーザーを消すだけでなく、Apple に対して revoke_token API を叩いて認証連携を解除します。これを忘れると、ユーザーは「設定 → Apple ID → サインインに使用中の App」に幽霊アカウントが残り続けます
nonce があると、サーバーは「自分が今回の認証セッションのために発行した raw nonce」と「ID トークン内の hashed nonce」が一致することを要求します。攻撃者は raw nonce を知らないので、トークンをそのまま使い回しても認証が通りません。これが「リプレイ攻撃を防ぐ」の中身です。
ここが本記事の核です。Cloudflare Workers + Hono + jose という組み合わせで書きます。Hono を選んでいる理由は、Workers との相性と、ミドルウェアの書きやすさです。
// src/auth/verify-apple.tsimport { jwtVerify, createRemoteJWKSet } from "jose";const APPLE_ISSUER = "https://appleid.apple.com";const APPLE_JWKS = createRemoteJWKSet( new URL("https://appleid.apple.com/auth/keys"));export type VerifiedAppleIdentity = { sub: string; // Apple のユーザー一意 ID(このアプリ内では永続) email?: string; emailVerified?: boolean; isPrivateEmail?: boolean;};export async function verifyAppleIdentityToken(params: { identityToken: string; expectedAudience: string; // Apple Developer の Service ID または Bundle ID rawNonce: string;}): Promise<VerifiedAppleIdentity> { const { identityToken, expectedAudience, rawNonce } = params; // 1) JWS 署名検証 + iss / aud / exp の自動チェック const { payload } = await jwtVerify(identityToken, APPLE_JWKS, { issuer: APPLE_ISSUER, audience: expectedAudience, }); // 2) nonce 検証 — Apple が hashed nonce を返してくる const expectedHashedNonce = await sha256Hex(rawNonce); if (payload.nonce !== expectedHashedNonce) { throw new Error("Nonce mismatch — possible replay attack"); } // 3) sub を必ず確認 if (typeof payload.sub !== "string" || payload.sub.length === 0) { throw new Error("Missing sub claim in Apple ID token"); } return { sub: payload.sub, email: typeof payload.email === "string" ? payload.email : undefined, emailVerified: payload.email_verified === true || payload.email_verified === "true", isPrivateEmail: payload.is_private_email === true || payload.is_private_email === "true", };}async function sha256Hex(input: string): Promise<string> { const data = new TextEncoder().encode(input); const hash = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, "0")) .join("");}
ここで意図的に省略していないのが、expectedAudience の扱いです。iOS ネイティブのアプリから直接 Sign in with Apple を呼んでいる場合、aud は「アプリの Bundle ID」が入ります(例: com.dolice.myrorkapp)。Web 側にも Sign in with Apple を載せている場合は、Service ID(例: com.dolice.myrorkapp.web)が aud に入るので、両方を許容する実装にする必要があります。
ここを Bundle ID 1 つだけハードコードしておくと、後で Web ログインを追加した瞬間に「Web からログインできない」というバグになります。最初から配列で持っておく方が安全です。
Step 4: ユーザー作成と「メールが 1 度しか取れない」問題
Sign in with Apple のもっとも嫌な仕様は、email と fullName が初回ログイン時にしか取得できないことです。2 回目以降のログインでは、Apple は email・fullName をすべて空で返してきます(これは仕様であり、Apple のサーバー側で「初回フラグ」が立っていると返ってこなくなります)。
この実装の急所は、apple_sub を一意キーにして、email を任意項目にすることです。Apple のプライベートリレーメール(@privaterelay.appleid.com で終わるアドレス)も、ユーザーが Apple ID 設定からいつでも遮断できるので、メール経由で連絡する設計にしている場合は別途「自前で確認したメール」を持つカラムを足してください。
そして、Sign in with Apple を使っている場合は、Apple に対しても「このアプリとの連携を解除して良い」と通知する必要があります。これが revoke_token API です。これを呼ばないと、ユーザーの Apple ID 設定画面に「このアプリでサインインに使用中」という幽霊エントリが残り続け、レビュアーから次のような指摘が飛んできます。
Your app does not adequately revoke the user's authentication credential when the account is deleted.
私はこのメッセージを 2 回受け取りました。実装は次の通りです。Apple が要求するのは Apple Developer ポータルで作った Service ID 用の client_secret(自分で署名する JWT)です。
// src/auth/apple-revoke.tsimport { SignJWT, importPKCS8 } from "jose";type AppleClientSecretParams = { teamId: string; // Apple Developer の Team ID(10桁) clientId: string; // Bundle ID または Service ID keyId: string; // Sign in with Apple 用に作った p8 鍵の Key ID privateKeyPkcs8: string; // p8 鍵の中身(PEM 形式)};async function buildAppleClientSecret(p: AppleClientSecretParams) { const now = Math.floor(Date.now() / 1000); const key = await importPKCS8(p.privateKeyPkcs8, "ES256"); return await new SignJWT({}) .setProtectedHeader({ alg: "ES256", kid: p.keyId }) .setIssuer(p.teamId) .setIssuedAt(now) .setExpirationTime(now + 60 * 5) // 最大 6 ヶ月だが短め推奨 .setAudience("https://appleid.apple.com") .setSubject(p.clientId) .sign(key);}export async function revokeAppleToken(p: { refreshToken: string; // Apple から受け取った refresh_token clientId: string; clientSecret: string; // 上の buildAppleClientSecret の戻り値}): Promise<void> { const body = new URLSearchParams({ client_id: p.clientId, client_secret: p.clientSecret, token: p.refreshToken, token_type_hint: "refresh_token", }); const res = await fetch("https://appleid.apple.com/auth/revoke", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); if (!res.ok) { const err = await res.text(); throw new Error(`Apple revoke failed: ${res.status} ${err}`); }}
ここで気付くのは、revoke_token 呼び出しには refresh_token が必要だということです。Sign in with Apple の refresh_token は、初回ログイン時に authorizationCode を Apple の /auth/token エンドポイントと交換することで取得できます。つまり、ログイン時に「将来削除する権利」を確保しておかないと、削除のときになって取れません。
// 初回ログイン時に refresh_token を取得して DB に保存async function exchangeAuthCodeForRefreshToken( authorizationCode: string, clientId: string, clientSecret: string): Promise<{ refreshToken: string }> { const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code: authorizationCode, grant_type: "authorization_code", }); const res = await fetch("https://appleid.apple.com/auth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`); const json = (await res.json()) as { refresh_token: string }; return { refreshToken: json.refresh_token };}
そして削除エンドポイントは次のようにまとめます。
app.post("/account/delete", async (c) => { const userId = c.get("userId"); // セッション認証ミドルウェアで設定済み const row = await c.env.DB.prepare( "SELECT apple_refresh_token FROM users WHERE id = ?1" ) .bind(userId) .first<{ apple_refresh_token: string | null }>(); // 1) Apple との連携解除を先にやる(失敗したらユーザーデータは消さない) if (row?.apple_refresh_token) { const clientSecret = await buildAppleClientSecret({ teamId: c.env.APPLE_TEAM_ID, clientId: "com.dolice.myrorkapp", keyId: c.env.APPLE_KEY_ID, privateKeyPkcs8: c.env.APPLE_PRIVATE_KEY_P8, }); await revokeAppleToken({ refreshToken: row.apple_refresh_token, clientId: "com.dolice.myrorkapp", clientSecret, }); } // 2) ユーザーデータ削除(外部キーは ON DELETE CASCADE 推奨) await c.env.DB.prepare("DELETE FROM users WHERE id = ?1").bind(userId).run(); return c.json({ ok: true });});
順序が重要で、Apple への revoke を先にやってからローカル DB を消します。逆順だと、revoke に失敗したときにユーザーデータだけが消えて、Apple 側の連携が残った「最悪の片寄り状態」になります。
Step 6: 通常ログイン経路と Apple ログインを共存させる
Sign in with Apple だけしか提供していないアプリは、現実にはほぼありません。メールアドレス + パスワードや Sign in with Google も提供している場合、「同じユーザーが両方の経路でログインしてきた」ケースをどう扱うかが設計の分かれ目になります。
apple_sub ではなく email でユーザーを引く: プライベートリレーが切り替わる、メールアドレスが Apple ID 側で変更されるなどの理由で、email は永続キーになり得ません
アカウント削除後に Apple へ revoke を呼ばない: ガイドライン 5.1.1(v) と Apple 公式ガイドの両方で求められています。revoke 失敗時はユーザーに通知し、削除自体はリトライ可能にしておくのが安全です
全体を振り返って — 次にやる 1 つのこと
ここまで実装すれば、Sign in with Apple 起因のリジェクトはほぼ消えます。長くなりましたが、もし「今日のうちに 1 つだけ進めるなら」を選ぶなら、**先に「アカウント削除エンドポイント + Apple revoke の動作確認」**から手を付けることをおすすめします。理由はシンプルで、ここが一番リジェクトされやすく、かつテストもしにくい部分だからです。設計を後回しにすると、リリース直前に焦って組むことになります。