nonce の検証コードを書いていた夜、いちばん長く手が止まったのは暗号の部分ではありませんでした。その nonce を、いったい誰が作るのか ——そこでした。クライアントが作った値を、サーバーが「発行済みか」と探しに行く。読み返すと、最初から噛み合っていない構成でした。いま思えば、暗号の細部より先に決めるべきだったのは、値の出どころだったのかもしれません。
Sign in with Apple は、ボタンを置くだけなら十分もかかりません。ただ、サーバーが ID トークンを確かめ、アカウント削除のときに Apple へ連携解除を伝えるところまで含めると、一つの認証基盤を組む作業になります。Rork が生成してくれる画面はたいてい前半だけを満たしていて、後半は自分で埋めることになります。
このガイドでは、クライアントに expo-apple-authentication、サーバーに Cloudflare Workers(Hono と D1)を使い、nonce の発行から ID トークンの検証、authorizationCode の交換、revoke を伴う削除、Apple からの通知の受信までを順に組んでいきます。Vercel Edge Functions や AWS Lambda でも、考え方はそのまま使えます。
審査で見られる点を、正確に押さえる
リジェクトの文面は定型ではなく、何が足りないのかを自分で突き止めることになりがちです。先に、関係するガイドラインの骨格を確認しておきます。文面は改訂されるため、最終的には App Store Review Guidelines の最新版でご確認ください。
4.8(ログインサービス) : サードパーティや SNS のログインを使うアプリは、同等の選択肢として、プライバシーに配慮したログインサービスを併せて提供する必要があります。2024 年 1 月の改訂以降の書き方は「Sign in with Apple そのもの」ではなく、収集する情報を氏名とメールアドレスに限ること、メールアドレスを非公開にできること、同意なしに広告目的でアプリ内の行動を追跡しないこと、という条件を満たす同等のサービスです。Sign in with Apple はその条件を満たす最も手軽な手段、という位置づけです。自社のアカウントだけを使うアプリは対象外です
5.1.1(v)(アカウント削除) : アカウントを作成できるアプリは、アプリ内からアカウントを削除できる必要があります。2022 年 6 月 30 日以降は必須で、Sign in with Apple を使っている場合は、削除の開始時に Sign in with Apple の REST API でトークンを取り消す(revoke する)ことも求められています。「何タップ以内」といった数値の規定は、私の知る限りありません。見つけやすい場所にあること、が基準です
ID トークンの検証 : Apple のドキュメントは、サーバーで JWS 署名と各クレームを検証する手順を示しています。クライアントが受け取った値をそのまま信じて DB に書く構成は、この手順を飛ばしていることになります
加えて、expo-apple-authentication は Expo Go では確認しづらく、ネイティブのエンタイトルメントが必要です。app.json の ios.usesAppleSignIn を有効にし、Apple Developer の App ID で Sign in with Apple を有効にしたうえで、開発ビルドを作り直します。手順は Expo Dev Client の導入記事 にまとめています。
全体像 — 4 つの層と、5 つの値の寿命
役割を揃えておくと、あとで不具合を切り分けるのが格段に楽になります。
層 1: 画面(Rork が生成する React Native) : ボタンを表示し、signInAsync() を呼び、identityToken と authorizationCode を受け取ります
層 2: クライアントの前処理 : サーバーから nonce を受け取り、SHA-256 でハッシュして Apple に渡します
層 3: サーバーの検証(Workers + Hono) : nonce を消費し、ID トークンを検証し、authorizationCode を交換して、自前のセッションを発行します
層 4: 削除と通知 : アカウント削除時の revoke と、Apple から届くサーバー間通知を扱います
この 4 つの層を、次の 5 つの値が行き来します。寿命が違うので、どれをどこに残すのかを表にしておきます。
authorizationCode が 5 分で失効し、一度しか使えないという点は、見落とされやすい部分です。ログインの応答を返したあとで交換しようとしても、もう間に合いません。
Step 1: サーバーが nonce を発行する
最初に決めるのは、nonce の出どころです。ここをサーバーに置くと、以降の検証がすべて素直につながります。
-- D1: nonce は 5 分で失効する使い捨ての値
CREATE TABLE apple_nonces (
nonce TEXT PRIMARY KEY ,
expires_at INTEGER NOT NULL -- UNIX 秒
);
CREATE TABLE users (
id TEXT PRIMARY KEY ,
apple_sub TEXT UNIQUE ,
email TEXT ,
display_name TEXT ,
apple_refresh_token_enc TEXT ,
status TEXT NOT NULL DEFAULT 'active' , -- active | pending_deletion
created_at TEXT NOT NULL
);
// src/routes/auth-apple-nonce.ts
import { Hono } from "hono" ;
type Env = { DB : D1Database };
const app = new Hono <{ Bindings : Env }>();
app. post ( "/auth/apple/nonce" , async ( c ) => {
const bytes = crypto. getRandomValues ( new Uint8Array ( 32 ));
const raw = [ ... bytes]. map (( b ) => b. toString ( 16 ). padStart ( 2 , "0" )). join ( "" );
const expiresAt = Math. floor (Date. now () / 1000 ) + 300 ;
await c.env. DB . prepare (
"INSERT INTO apple_nonces (nonce, expires_at) VALUES (?1, ?2)"
)
. bind (raw, expiresAt)
. run ();
return c. json ({ nonce: raw });
});
export default app;
消費する側は、「存在を確かめてから削除する」の二手ではなく、削除と確認を一文で済ませます。
// src/auth/nonce.ts
export async function consumeNonce ( db : D1Database , raw : string ) : Promise < boolean > {
const now = Math. floor (Date. now () / 1000 );
const row = await db
. prepare ( "DELETE FROM apple_nonces WHERE nonce = ?1 AND expires_at > ?2 RETURNING nonce" )
. bind (raw, now)
. first ();
return row !== null ;
}
KV に置く案もよく見かけますが、Workers KV は結果整合のストレージなので、get のあとに delete する二手では、同じ nonce を同時に二回通せる隙間が残ります。使い捨てを厳密に守りたいなら、D1 の DELETE ... RETURNING か、Durable Objects のように一つの場所で直列化できる仕組みが向いています。
Step 2: クライアントはハッシュして Apple に渡す
サーバーから受け取った raw nonce を SHA-256 のハッシュにして、Apple に渡します。ID トークンの nonce クレームには、渡したハッシュ値がそのまま入って返ってきます。サーバーは、戻ってきた raw nonce を同じ方法でハッシュして照合します。
// app/(auth)/sign-in-apple.tsx
import * as AppleAuthentication from "expo-apple-authentication" ;
import * as Crypto from "expo-crypto" ;
import * as SecureStore from "expo-secure-store" ;
import { Alert, Platform } from "react-native" ;
import { useState } from "react" ;
import { useRouter } from "expo-router" ;
const API_BASE = process.env. EXPO_PUBLIC_API_BASE ! ;
async function fetchNonce () : Promise < string > {
const res = await fetch ( `${ API_BASE }/auth/apple/nonce` , { method: "POST" });
if ( ! res.ok) throw new Error ( "nonce_unavailable" );
return (( await res. json ()) as { nonce : string }).nonce;
}
export function AppleSignInButton () {
const router = useRouter ();
const [ busy , setBusy ] = useState ( false );
const handlePress = async () => {
if (busy) return ;
setBusy ( true );
try {
const raw = await fetchNonce ();
const hashed = await Crypto. digestStringAsync (
Crypto.CryptoDigestAlgorithm. SHA256 ,
raw,
{ encoding: Crypto.CryptoEncoding. HEX }
);
const credential = await AppleAuthentication. signInAsync ({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope. FULL_NAME ,
AppleAuthentication.AppleAuthenticationScope. EMAIL ,
],
nonce: hashed, // Apple に渡すのはハッシュの方
});
const res = await fetch ( `${ API_BASE }/auth/apple` , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({
identityToken: credential.identityToken,
authorizationCode: credential.authorizationCode,
rawNonce: raw, // サーバーには元の値を戻す
fullName: credential.fullName, // 初回の認可でしか入らない
}),
});
if ( ! res.ok) throw new Error ( `server_verification_failed:${ res . status }` );
const { sessionToken } = ( await res. json ()) as { sessionToken : string };
await SecureStore. setItemAsync ( "session_token" , sessionToken);
await SecureStore. setItemAsync ( "apple_user" , credential.user);
router. replace ( "/(app)/home" );
} catch ( e : unknown ) {
const code = (e as { code ?: string }).code;
if (code === "ERR_REQUEST_CANCELED" ) return ; // ユーザーが閉じただけ
Alert. alert ( "ログインに失敗しました" , "時間をおいて、もう一度お試しください。" );
} finally {
setBusy ( false );
}
};
if (Platform. OS !== "ios" ) return null ;
return (
< AppleAuthentication.AppleAuthenticationButton
buttonType = { AppleAuthentication.AppleAuthenticationButtonType. SIGN_IN }
buttonStyle = { AppleAuthentication.AppleAuthenticationButtonStyle. BLACK }
cornerRadius = { 8 }
style = { { width: "100%" , height: 50 } }
onPress = { handlePress }
/>
);
}
キャンセルの判定は、エラーメッセージの文字列ではなく code で行います。文言は版によって変わりますが、ERR_REQUEST_CANCELED というコードは変わりにくいためです。サーバーのエラー本文をそのままアラートに出す書き方も、画面生成のままだと残りがちなので、固定の文言に差し替えています。
Step 3: nonce が守っているものを、一段だけ深く
「nonce は付けるもの」という理解のままだと、細部で必ず崩れます。何を防いでいるのかを、攻撃の形で確認しておきます。
誰かの identityToken が何らかの形で漏れたとします。nonce の検証がなければ、そのトークンをサーバーに投げるだけで、被害者としてログインできてしまいます。
ここで効くのは、nonce の出どころ です。クライアントが自分で作った nonce だと、攻撃者はトークンと raw nonce をひと組にして再送できてしまい、検証をすり抜けます。サーバーが発行し、一度使ったら消えるようにします。この条件が揃って初めて、「この ID トークンは、サーバーが今しがた許可したログイン試行のためのものだ」と言えるようになります。
nonce は、検証する側が発行し、一度だけ使わせます。 暗号の細工よりも、この線引きのほうが効いてきます。
Step 4: ID トークンを検証し、authorizationCode をその場で交換する
サーバー側の中心です。jose で署名とクレームを確かめ、続けて authorizationCode を交換します。
// src/auth/verify-apple.ts
import { jwtVerify, createRemoteJWKSet } from "jose" ;
const APPLE_ISSUER = "https://appleid.apple.com" ;
// モジュールスコープに置き、リクエストごとに作り直さない
export const APPLE_JWKS = createRemoteJWKSet ( new URL ( "https://appleid.apple.com/auth/keys" ));
// ネイティブは Bundle ID、Web や REST 経由は Service ID が aud に入る
export const ACCEPTED_AUDIENCES = [ "com.example.myrorkapp" , "com.example.myrorkapp.web" ];
export type VerifiedAppleIdentity = {
sub : string ;
email ?: string ;
emailVerified : boolean ;
isPrivateEmail : boolean ;
};
export async function verifyAppleIdentityToken ( params : {
identityToken : string ;
rawNonce : string ;
}) : Promise < VerifiedAppleIdentity > {
// 署名・iss・aud・exp をまとめて検証
const { payload } = await jwtVerify (params.identityToken, APPLE_JWKS , {
issuer: APPLE_ISSUER ,
audience: ACCEPTED_AUDIENCES ,
});
// nonce クレームには、クライアントが渡したハッシュがそのまま入る
const expected = await sha256Hex (params.rawNonce);
if (payload.nonce !== expected) throw new Error ( "nonce_mismatch" );
if ( typeof payload.sub !== "string" || payload.sub. length === 0 ) {
throw new Error ( "missing_sub" );
}
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 hash = await crypto.subtle. digest ( "SHA-256" , new TextEncoder (). encode (input));
return [ ...new Uint8Array (hash)]. map (( b ) => b. toString ( 16 ). padStart ( 2 , "0" )). join ( "" );
}
aud は 1 つに固定せず、最初から配列で持っておきます。あとから Web 版のログインを足したとき、Service ID が弾かれて初めて気づく、という事態を避けられます。
鍵の取得については、createRemoteJWKSet が取得した鍵セットを保持し、未知の kid が来たときだけ再取得する作りになっています(再取得には待ち時間の制限があります。既定値は版によって異なるため、お使いの版の README でご確認ください)。自前で鍵のキャッシュを書き起こすより、まずこの標準の挙動に任せるほうが安全です。Workers では isolate ごとに状態が分かれるため、cold start のたびに鍵を取りに行くことは前提にしておきます。
続いて authorizationCode を交換します。ここで refresh_token を手に入れておかないと、削除のときに revoke できません。
// src/auth/apple-client.ts
import { SignJWT, importPKCS8 } from "jose" ;
export async function buildAppleClientSecret ( p : {
teamId : string ; // 10 桁の Team ID
clientId : string ; // ネイティブなら Bundle ID、Web なら Service ID
keyId : string ; // Sign in with Apple を有効にした鍵の Key ID
privateKeyPkcs8 : string ; // .p8 の中身(PEM)
}) : Promise < string > {
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 + 300 ) // 都度生成するので短くてよい
. setAudience ( "https://appleid.apple.com" )
. setSubject (p.clientId)
. sign (key);
}
export async function exchangeAuthorizationCode ( p : {
code : string ;
clientId : string ;
clientSecret : string ;
}) : Promise < string > {
const res = await fetch ( "https://appleid.apple.com/auth/token" , {
method: "POST" ,
headers: { "Content-Type" : "application/x-www-form-urlencoded" },
body: new URLSearchParams ({
client_id: p.clientId,
client_secret: p.clientSecret,
code: p.code,
grant_type: "authorization_code" ,
}),
});
if ( ! res.ok) throw new Error ( `apple_token_exchange_failed:${ res . status }` );
return (( await res. json ()) as { refresh_token : string }).refresh_token;
}
export async function revokeAppleToken ( p : {
refreshToken : string ;
clientId : string ;
clientSecret : string ;
}) : Promise < void > {
const res = await fetch ( "https://appleid.apple.com/auth/revoke" , {
method: "POST" ,
headers: { "Content-Type" : "application/x-www-form-urlencoded" },
body: new URLSearchParams ({
client_id: p.clientId,
client_secret: p.clientSecret,
token: p.refreshToken,
token_type_hint: "refresh_token" ,
}),
});
if ( ! res.ok) throw new Error ( `apple_revoke_failed:${ res . status }` );
}
client_secret は、リクエストのたびに作り直しています。有効期限は最長 6 か月まで指定できますが、環境変数に作り置きしておくと、期限が切れた日に交換も revoke も一斉に invalid_client で失敗します。署名の計算は軽いので、都度生成にしてしまえば、期限の管理という仕事そのものがなくなります。
refresh_token は、保存する前に暗号化します。Workers の Secret に 32 バイトの鍵を base64 で置き、AES-GCM で包む形です。
// src/auth/secret-box.ts
async function importKey ( base64Key : string ) {
const raw = Uint8Array. from ( atob (base64Key), ( ch ) => ch. charCodeAt ( 0 ));
return crypto.subtle. importKey ( "raw" , raw, "AES-GCM" , false , [ "encrypt" , "decrypt" ]);
}
export async function encryptSecret ( plain : string , base64Key : string ) : Promise < string > {
const key = await importKey (base64Key);
const iv = crypto. getRandomValues ( new Uint8Array ( 12 ));
const ct = new Uint8Array (
await crypto.subtle. encrypt ({ name: "AES-GCM" , iv }, key, new TextEncoder (). encode (plain))
);
const out = new Uint8Array (iv. length + ct. length );
out. set (iv);
out. set (ct, iv. length );
return btoa (String. fromCharCode ( ... out));
}
export async function decryptSecret ( boxed : string , base64Key : string ) : Promise < string > {
const key = await importKey (base64Key);
const all = Uint8Array. from ( atob (boxed), ( ch ) => ch. charCodeAt ( 0 ));
const plain = await crypto.subtle. decrypt (
{ name: "AES-GCM" , iv: all. slice ( 0 , 12 ) },
key,
all. slice ( 12 )
);
return new TextDecoder (). decode (plain);
}
Step 5: ユーザーの作成 — メールの「初回しか取れない」を整理する
Sign in with Apple でよく語られるのが、メールと氏名は初回しか取れない、という話です。整理すると、次のようになります。
credential.fullName と credential.email は、その認可が初回のときにしか入りません 。氏名を取り損ねたら、同じ方法では二度と取れません
一方、ID トークンの email クレームは、再サインインでも含まれることが多いようです。ただし Apple ID の状態や要求したスコープにも左右されるため、「必ずある」とは決め打ちしません。ご自身のテスト用 Apple ID で、挙動を一度確かめておいてください
クライアントから送られてきた email は、検証を通っていない値です。サーバーは検証済みのクレームだけを信用し、クライアントの値は DB に書きません
氏名は表示用の値であり、検証の対象ではありません。クライアントから受け取って保存して構いませんが、空でも作成が通る設計にします。
// src/routes/auth-apple.ts
import { Hono } from "hono" ;
import { consumeNonce } from "../auth/nonce" ;
import { verifyAppleIdentityToken } from "../auth/verify-apple" ;
import { buildAppleClientSecret, exchangeAuthorizationCode } from "../auth/apple-client" ;
import { encryptSecret } from "../auth/secret-box" ;
import { issueSessionToken } from "../auth/session" ;
type Env = {
DB : D1Database ;
APPLE_TEAM_ID : string ;
APPLE_KEY_ID : string ;
APPLE_PRIVATE_KEY_P8 : string ;
TOKEN_BOX_KEY : string ;
};
const CLIENT_ID = "com.example.myrorkapp" ; // ネイティブの Bundle ID
const app = new Hono <{ Bindings : Env }>();
app. post ( "/auth/apple" , async ( c ) => {
const body = await c.req. json <{
identityToken : string ;
authorizationCode : string ;
rawNonce : string ;
fullName ?: { givenName ?: string | null ; familyName ?: string | null } | null ;
}>();
// 1) nonce を原子的に消費(なければ 401)
if ( ! ( await consumeNonce (c.env. DB , body.rawNonce))) {
return c. json ({ error: "nonce_invalid" }, 401 );
}
// 2) ID トークンの検証
let identity;
try {
identity = await verifyAppleIdentityToken ({
identityToken: body.identityToken,
rawNonce: body.rawNonce,
});
} catch {
return c. json ({ error: "token_invalid" }, 401 );
}
const existing = await c.env. DB . prepare (
"SELECT id, email, status, apple_refresh_token_enc FROM users WHERE apple_sub = ?1"
)
. bind (identity.sub)
. first <{ id : string ; email : string | null ; status : string ; apple_refresh_token_enc : string | null }>();
if (existing?.status === "pending_deletion" ) {
return c. json ({ error: "account_deleting" }, 403 );
}
// 3) authorizationCode は 5 分・一度きり。交換はこの場で行います
const needsToken = ! existing || ! existing.apple_refresh_token_enc;
let refreshTokenEnc : string | null = null ;
if (needsToken) {
try {
const clientSecret = await buildAppleClientSecret ({
teamId: c.env. APPLE_TEAM_ID ,
clientId: CLIENT_ID ,
keyId: c.env. APPLE_KEY_ID ,
privateKeyPkcs8: c.env. APPLE_PRIVATE_KEY_P8 ,
});
const refreshToken = await exchangeAuthorizationCode ({
code: body.authorizationCode,
clientId: CLIENT_ID ,
clientSecret,
});
refreshTokenEnc = await encryptSecret (refreshToken, c.env. TOKEN_BOX_KEY );
} catch {
// 新規ユーザーは、revoke できないアカウントを作らないための停止点
if ( ! existing) return c. json ({ error: "token_exchange_failed" }, 502 );
}
}
let userId : string ;
if (existing) {
userId = existing.id;
if (refreshTokenEnc) {
await c.env. DB . prepare ( "UPDATE users SET apple_refresh_token_enc = ?1 WHERE id = ?2" )
. bind (refreshTokenEnc, userId)
. run ();
}
if ( ! existing.email && identity.email) {
await c.env. DB . prepare ( "UPDATE users SET email = ?1 WHERE id = ?2" )
. bind (identity.email, userId)
. run ();
}
} else {
userId = crypto. randomUUID ();
const displayName = [body.fullName?.givenName ?? "" , body.fullName?.familyName ?? "" ]
. filter (Boolean)
. join ( " " )
. trim ();
await c.env. DB . prepare (
`INSERT INTO users (id, apple_sub, email, display_name, apple_refresh_token_enc, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
)
. bind (
userId,
identity.sub,
identity.email ?? null , // 検証済みクレームのみ
displayName || null ,
refreshTokenEnc,
new Date (). toISOString ()
)
. run ();
}
return c. json ({ sessionToken: await issueSessionToken (c.env, userId) });
});
export default app;
apple_sub を一意キーにして、email は空でも通します。この 2 点が急所です。sub は、同じ Apple ID と同じ Team の組み合わせでは安定した値になります。メールアドレスは、プライベートリレーの切り替えや Apple ID 側の変更で動き得るため、永続キーには向きません。
Step 6: アカウント削除 — revoke を失敗させない作りにする
5.1.1(v) のリジェクトを避けるだけなら、削除ボタンと削除 API があれば足ります。ただし Sign in with Apple を使っているなら、Apple 側の連携も解除する ところまでが一組です。解除しないと、ユーザーの Apple ID 設定の「サインインとセキュリティ」→「Apple でサインイン」に、削除したはずのアプリが残り続けます。
悩ましいのは、revoke が失敗したときです。ローカルの DB を先に消すと、Apple 側の連携だけが残ります。revoke を先にして成功してから消す順序にしても、失敗したときにユーザーを待たせたままになります。そこで、削除を二段階に分けます。
削除の要求を受けた時点で、status を pending_deletion にして、ログインとセッションを止めます
revoke を試み、成功したら DB から物理削除します。失敗したら pending_deletion のまま残し、Cron Triggers で定期的に再試行します
// src/routes/account-delete.ts
import { decryptSecret } from "../auth/secret-box" ;
import { buildAppleClientSecret, revokeAppleToken } from "../auth/apple-client" ;
export async function tryRevokeAndPurge ( env : Env , userId : string ) : Promise < boolean > {
const row = await env. DB . prepare (
"SELECT apple_refresh_token_enc FROM users WHERE id = ?1"
)
. bind (userId)
. first <{ apple_refresh_token_enc : string | null }>();
if (row?.apple_refresh_token_enc) {
try {
const refreshToken = await decryptSecret (row.apple_refresh_token_enc, env. TOKEN_BOX_KEY );
const clientSecret = await buildAppleClientSecret ({
teamId: env. APPLE_TEAM_ID ,
clientId: CLIENT_ID ,
keyId: env. APPLE_KEY_ID ,
privateKeyPkcs8: env. APPLE_PRIVATE_KEY_P8 ,
});
await revokeAppleToken ({ refreshToken, clientId: CLIENT_ID , clientSecret });
} catch (e) {
console. error ( "apple_revoke_failed" , String (e)); // トークン本体はログに含めません
return false ;
}
}
await env. DB . prepare ( "DELETE FROM users WHERE id = ?1" ). bind (userId). run ();
return true ;
}
app. post ( "/account/delete" , async ( c ) => {
const userId = c. get ( "userId" );
await c.env. DB . prepare ( "UPDATE users SET status = 'pending_deletion' WHERE id = ?1" )
. bind (userId)
. run ();
const done = await tryRevokeAndPurge (c.env, userId);
// 202 でも、ユーザーには「削除を受け付けました」と伝えて構いません
return c. json ({ ok: true , purged: done }, done ? 200 : 202 );
});
// wrangler の Cron Triggers からの呼び出し口
export async function retryPendingDeletions ( env : Env ) {
const { results } = await env. DB . prepare (
"SELECT id FROM users WHERE status = 'pending_deletion' LIMIT 50"
). all <{ id : string }>();
for ( const r of results) await tryRevokeAndPurge (env, r.id);
}
revoke で invalid_client が返るときは、client_secret の署名に使った鍵、Team ID、clientId(Bundle ID か Service ID か)の組み合わせを疑います。鍵に Sign in with Apple が有効になっていないことが原因になる場合もあります。
Step 7: Apple 側で起きた変更を受け取る
ユーザーは、アプリを通さずに連携を解除できます。iPhone の設定から「Apple でサインイン」の項目を開いて、アプリを止めてしまう操作です。サーバーが何も知らないままだと、その refresh_token は使えなくなったまま残ります。
Apple Developer の設定でサーバー間通知のエンドポイントを登録しておくと、メールの転送設定の変更、連携の解除、Apple ID の削除といった出来事が、署名付きの JWT として届きます。
// src/routes/webhooks-apple.ts
import { jwtVerify } from "jose" ;
import { APPLE_JWKS, ACCEPTED_AUDIENCES } from "../auth/verify-apple" ;
app. post ( "/webhooks/apple/siwa" , async ( c ) => {
const { payload } = await c.req. json <{ payload : string }>();
const { payload : claims } = await jwtVerify (payload, APPLE_JWKS , {
issuer: "https://appleid.apple.com" ,
audience: ACCEPTED_AUDIENCES ,
});
// events は JSON 文字列。実際の形は届いた実物で要確認
const event = JSON . parse (claims.events as string ) as { type : string ; sub : string };
if (event.type === "consent-revoked" || event.type === "account-delete" ) {
await c.env. DB . prepare (
"UPDATE users SET apple_refresh_token_enc = NULL WHERE apple_sub = ?1"
)
. bind (event.sub)
. run ();
// 該当ユーザーのセッション失効はここ(自前の実装に合わせた処理)
}
return c. body ( null , 200 );
});
account-delete を受けたときにデータをどう扱うかは、プライバシーポリシーとの兼ね合いで決める部分です。通知を受けた時点でトークンを破棄し、セッションを止めるところまでは、どの設計でも共通です。
アプリ側でも、起動のたびに状態を確かめておくと取りこぼしが減ります。Step 2 で保存した apple_user を使います。
import * as AppleAuthentication from "expo-apple-authentication" ;
import * as SecureStore from "expo-secure-store" ;
export async function ensureAppleCredentialStillValid () : Promise < boolean > {
const user = await SecureStore. getItemAsync ( "apple_user" );
if ( ! user) return true ; // Apple 以外でのログインは対象外
const state = await AppleAuthentication. getCredentialStateAsync (user);
const S = AppleAuthentication.AppleAuthenticationCredentialState;
return state === S . AUTHORIZED ; // REVOKED / NOT_FOUND ならサインアウトへ
}
Step 8: 通常ログインと共存させる
Sign in with Apple だけを提供するアプリは、現実にはほとんどありません。メールとパスワード、あるいは Google も併せて提供するなら、「同じ人が両方の経路から入ってきた」ときの扱いが設計の分かれ目になります。
私は、メールアドレスが同じだからといって、アカウントを自動では結合しない設計にしています。Apple のプライベートリレーは別のアドレスを返すため、同じ人でもメールが食い違うことが構造的に起こるからです。結合するなら、本人が「これは自分のアカウントです」と明示的に承認する画面を挟むほうが、取り違えのリスクを抑えられます。
CREATE TABLE auth_providers (
user_id TEXT NOT NULL ,
provider TEXT NOT NULL , -- 'apple' | 'google' | 'password'
provider_user_id TEXT NOT NULL , -- Apple や Google の sub
created_at TEXT NOT NULL ,
PRIMARY KEY ( provider , provider_user_id)
);
1 人のユーザーに複数の provider をぶら下げられる形にしておけば、「Apple を外して Google だけにしたい」という要望にも、表の行を足し引きするだけで応えられます。なお、この表を使う場合は、Step 5 の users.apple_sub と役割が重なります。どちらを正本にするかを、最初に決めておいてください。
Step 9: 失敗の形を残す
認証の不具合は、あとから調べようとしても手がかりが残らないのが厄介です。「ログインできない」という連絡が来ても、こちらのログには何も出ていないのです。そこで、出荷の前に 3 か所だけ、構造化したログを仕込みます。
// src/auth/observability.ts
type AppleAuthEvent =
| { stage : "nonce" ; outcome : "ok" | "invalid" }
| { stage : "jwt_verify" ; outcome : "ok" | "bad_signature" | "wrong_audience" | "expired" }
| { stage : "token_exchange" ; outcome : "ok" | "failed" }
| { stage : "user_resolve" ; outcome : "existing" | "new" };
export function logAppleAuth ( event : AppleAuthEvent , env : { LOG_SINK : Queue }) {
// 生の nonce も ID トークンも対象外。段階と結果の形だけを記録
env. LOG_SINK . send ({ ts: new Date (). toISOString (), flow: "apple_auth" , ... event });
}
残すのは「どの段階で、どう失敗したか」だけです。トークンや nonce の値は、ログの保管先に認証情報を置かないために、あえて出しません。bad_signature の割合が普段より跳ねたときにアラートを上げておくと、鍵の入れ替えとキャッシュのずれにも早く気づけます。
Step 10: 出荷前の確認シナリオ
TestFlight や実機でしか見えない挙動があるため、シミュレーターで動いたことは、確認の入口にすぎません。次の 5 つを、出荷前に順に通します。
初めて使う Apple ID(メールを非公開にした状態)でサインインし、メールが空のままユーザーが作られること
アプリをサインアウトして同じ Apple ID で入り直し、sub から同じユーザーに解決されて、重複が作られないこと
アカウント削除を実行し、DB からユーザーが消えて、端末の「Apple でサインイン」の一覧からもそのアプリが消えること
削除のあとで同じ Apple ID から入り直し、新しいユーザーとして作られること。sub は同じ値になりますが、認可は改めて初回扱いになります
revoke を意図的に失敗させ(鍵を一時的に壊すなど)、pending_deletion のまま残り、Cron の再試行で最終的に削除まで進むこと
Step 11: 4.8 に対応する画面の見せ方
最後に、技術ではなく画面の話を一つだけ。Apple のデザインガイドラインは、Sign in with Apple のボタンを、ほかのログインボタンと同程度の大きさと目立ち方で置くことを求めています。私の理解では、次の点が見られやすいです。
ほかのボタンと同じ大きさの領域を確保し、同程度の優先度に見える位置に置きます。一番下に小さく置くのは避けます
自前でボタンを描かず、AppleAuthenticationButton を使います。ラベルも Apple が用意する文言(サインイン、続ける、サインアップ)から選びます
メールとパスワードだけのアプリは、4.8 の対象にはなりません。ただし、あとから Google を足すときに、ログイン画面の並びを見直す必要が出てきます
Rork に画面を生成させるときは、「ほかのログインボタンと同じ高さで並べてください」と一文を添えておくと、手直しが減ります。
運用に入ってから効いてくること
審査を通すための実装は、ここまでです。運用が始まってから効いてくるものを、数字は伏せて、整理して残します。数字は端末と Worker の置き場所で変わるため、ご自身のログで測っていただくのが確実です。
鍵の入れ替えは予告なく起きる : 鍵のキャッシュがヒットする前提のコードは書かないようにしています。Step 4 のとおり標準の挙動に任せ、bad_signature の増加をアラートで拾います。再取得を無条件に繰り返す実装は、Apple 側に弾かれる原因にもなります
client_secret は都度生成する : 作り置きの JWT が期限切れになる日は、交換と revoke が同時に止まります。原因がアプリのコードに見えないため、切り分けに時間を取られやすい部分です
削除は二段階にする : 一つのトランザクションで済ませようとすると、revoke が失敗した場合に、Apple 側の連携が残ったままユーザー情報だけが消える不整合が生まれます
プライベートリレー宛のメールは、登録した送信元からしか届かない : Apple Developer の「Sign in with Apple for Email Communication」で、送信に使うドメインやアドレスを登録し、SPF などの設定も合わせておきます。届かなかったときのために、重要な通知はアプリ内メッセージとして二重化しておきます
よくある間違い
実装中に、同じところで手が止まる方が多いと感じた点を挙げます。
クライアントから受け取った email をそのまま保存する : 検証を通っていない値は偽装できます。ID トークンの検証済みクレームだけを使います
nonce を、クライアントだけで作って検証した気になる : 発行と消費をサーバーに置かないと、リプレイを防ぐ意味が薄れます
authorizationCode の交換を、ログイン応答のあとに回す : 5 分で失効し、一度しか使えません。応答を返す前に交換します
apple_sub ではなく email でユーザーを引く : メールは動き得るため、永続キーにはなりません
client_secret を環境変数に作り置きする : 期限が切れた日に、交換と削除が同時に止まります
次にやる、1 つのこと
ここまで組めば、Sign in with Apple に由来する審査の指摘は大きく減らせるはずです。ただ、すべてを一度に整えようとすると手が止まります。まずは、テスト用の Apple ID で「サインイン → 削除 → 設定の一覧から消える」の一往復を通してみていただければと思います。削除と revoke の組が、いちばん指摘を受けやすく、いちばん確かめにくい部分だからです。私も、そこから順に固めるようにしています。