綴Photo の勉強ノートに戻る
開発記録2026.06.25

モバイルに本物のログインを足す③ — 中継をレビューに掛けたら、PKCE が守る線と『今は直さない』が見えた

セキュリティ認証PKCEコードレビュー学習綴Photo

モバイルに本物のログインを足す連載の、web 側の番。前回はアプリ側で「引換券で token を受け取る」ところまで作った。今回はその引換券を発行する web の中継ができたので、まるごとセキュリティレビューに掛けた記録です。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

綴Photo のログインは、アプリが端末のブラウザで自分の web を開いて、web 側でログイン → web がアプリに 使い捨ての引換券(ワンタイム code) を返し、アプリがそれを裏口で本物のトークン(=ログインしっぱなしを支える“合鍵”。これを持つ相手は本人として振る舞えるので一番大事)に換える、という流れにしてあります。引換券は60秒で消える使い捨て、トークンは長生きする本物の合鍵、という別物だと思ってほしい。前回作ったのはアプリ側(券を受け取って交換する)まで。今回その券を発行する web の中継ができたので、本番に出す前に Claude にまるごとレビューしてもらった。出てきた指摘を眺めて、自分がやったのは「どれを今直して、どれを後回しにするか」を決めることだった。学びが2つあったので残しておく。

まず、核は無事だった

レビューで一番気にしていたのは、設計の真ん中=「トークンそのものを URL に載せず、使い捨ての券だけを載せる」がちゃんと効いているか。ここは無事だった。券は60秒で失効して一度使ったら消えます。券を本物に換える裏口では PKCE で本人確認しています。リダイレクトを飛ばす先も、自分の端末(loopback)か専用スキームだけに絞られていて、知らない URL には券を出しません。致命的な穴はゼロ、という結論。

自分にとっての収穫は、レビューを通して「なぜ token を URL に載せないのか」を腹落ちできたこと。戻り道(リダイレクト URL)はブラウザ履歴やログに残る=漏れる前提で、一番長生きするトークンをそんな所に置きません。だから「漏れてもいい使い捨ての券」だけを置きます。前回なんとなく書いた設計の理由が、レビューで言語化された。

PKCE が守ってくれるのは「券だけ拾った他人」まで

ここからが1つ目の学び。PKCE は万能じゃない、という当たり前を、具体的な穴で突きつけられた。

アプリ(手元)web(サーバ)① 秘密(verifier)を作る② ハッシュ(challenge)だけ預けるハッシュは元に戻せない=challenge から verifier は割れない③ 使い捨ての券(code)を返す④ 券 + verifier を裏口で出す⑤ verifier を要約して ② と照合⑥ 一致 → 本物のトークンverifier は最後まで手元。web に渡すのは challenge だけ

秘密(verifier)は最後まで手元にあって、web に渡すのは戻せない要約だけ。だから 券だけ拾った他人は、verifier を知らないので交換できません。ここは守られます。

すり抜けるのは、challenge を最初から自分で仕込んだ相手です。

券だけ拾った他人challenge を自分で仕込んだ相手拾えたのは券(code)だけverifier は持っていない照合に出す秘密が無い交換は通らない= PKCE が守ってくれる線攻撃者が自分の challenge を仕込む被害者に開かせて券を発行させるverifier は最初から攻撃者の手元券を横取りできれば交換が通る= PKCE では弾けないPKCE は「券と verifier が別人のもの」を弾く仕掛け。「両方とも攻撃者のもの」は弾けない

攻撃者が自分の challenge を埋め込んだログインリンクを、web にログイン中の被害者に開かせます。すると被害者のアカウントの券が発行されます。その券を被害者の端末上で横取りできれば、verifier は攻撃者が自分で持っているから交換が通ってしまいます。

この穴が成立するには、券を横取りする経路=アプリと同じ呼び出しスキームを別アプリが横取り登録する、が要ります。それが起きるのはモバイルの custom scheme(アプリ名://... で戻る道)を足したとき。いまアプリが実際に使う戻り先は loopback(=自分の端末の内側だけを指す戻り先。外のネットには出ない。127.0.0.1)だけなので、横取りするには被害者のマシンに先に入り込んでいる必要がある=実質もう手遅れの状況でしか成立しません。サーバ側の許可リストには custom scheme も「将来枠」として書いてあるが、それを使うアプリがまだ居ません。

つまりこれは「まだ取り付けていないドアの鍵」の話だった。怖いけど、いま塞ぐ対象がそもそも開いていない。この穴が成立する前提は「発行された券を横取りできること」なので、モバイルで横取り経路(別アプリが同じ戻り道を名乗る)を消すのが App Links——custom scheme を足す日が、本当の急所になる。

直す・後回し・書いて予約、の3択を決めた

2つ目の学び。レビューが出した指摘を全部その場で直すのが正解じゃない、ということ。仕分けの軸は「いま実害が出るか」と「直すと、いま動いているものを壊すか」の2つで、指摘は4つの箱に分かれた。

レビューが出した指摘今やる直しても既存が壊れないものis_active チェックverifier の長さ→ この場で実装壊れるから待つ今のアプリの契約と合わないものredirect_uri の照合を足す→ 契約を変える回にドアがまだ無い成立する前提がまだ実装されてないPKCE すり抜けの穴を塞ぐ→ custom scheme の回に本番まで関係ないデプロイ構成の話券の置き場がプロセス内メモリ→ 本番デプロイ前後回しの3つは「いつ効いてくるか」を添えて設計書に予約する。添えないと消える

今やったのは2つだけ。無効化されたユーザーに券を換えさせない is_active チェックと、verifier の長さチェック(RFC で 43〜128 文字と決まっています)。どちらも AI に書いてもらった。長さチェックは「足すと既存アプリを壊さないか」を先に裏取りしてから入れた——アプリが出す verifier は必ず43文字ちょうど(32バイトを base64url で出すと43文字になる)と確認できたので、43未満を弾いても本物は通ります。

python
# verifier の長さ検証(RFC 7636 は 43〜128)。券を消費する前に弾く=不正入力で有効な券を焼かない。
# ↑「verifier」= 上の Callout の、アプリだけが知る秘密。43未満/129以上は正規クライアントが出さない値。
if not (VERIFIER_MIN_LENGTH <= len(code_verifier) <= VERIFIER_MAX_LENGTH):
    return Response({"detail": "code_verifier の形式が不正です"}, status=400)
...
user = User.objects.get(pk=entry["user_id"])
if not user.is_active:                       # 券発行〜交換の60秒で無効化された人に token を渡さない
    return Response({"detail": "ユーザーが無効です"}, status=400)

逆に言うと、わざと直さなかったもののほうが判断としては大きかった。「指摘が出た=直す」で機械的に潰すと、動いてるものを壊すか、無いドアに鍵を付けるだけになります。レビューの出力は片付ける TODO リストじゃなくて、仕分けの材料なんだと思った。

学びポイント

  • PKCE は「券と verifier が別人」を弾く仕掛けで、「両方とも攻撃者のもの」は弾けません。 だから custom scheme を足す日が本当の急所で、そこは App Links(OS がドメイン所有者を検証するので別アプリが同じ戻り道を名乗れない)とセットで設計します。
  • レビューの出力は片付ける TODO リストじゃなくて、仕分けの材料。 「今直す/今は直さない/書いて予約」に分けて、後回しはトリガー(custom scheme を作るとき/本番デプロイ前)を添えないと消えます。

おわりに

web の中継はこれでレビュー済みになって、安全な分の守りは入った。本当の山場はモバイルの戻り道(custom scheme)を足すときで、そこで PKCE のすり抜けにどう蓋をするか——は設計書に、トリガー付きで書いて待たせてある。次にモバイルを触るときの自分が、ドアを付ける前にこのメモを開けるといい。


この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。