Slack に送ったログインリンクが、人間が踏む前に無効化される — リンクプレビューがワンタイムトークンを消費する問題
メールプロバイダを持たない小規模サイトで、管理画面のログイン用マジックリンク(ワンタイムトークン付き URL)を Slack の Incoming Webhook に流す構成を使っています。メール基盤なしでパスワードレス認証が回るので気に入っているのですが、この構成には一つ、確実に踏む罠があります。
Slack に届いたログインリンクを人間がクリックする前に、Slack 自身がリンクを開いてワンタイムトークンを消費してしまうのです。
本記事では、この現象の正体(リンクプレビュー用クローラー)と、Webhook ペイロード側での対策、そして「ワンタイムトークンを GET で消費する設計」自体への教訓をまとめます。
前提: Slack の Incoming Webhook / chat.postMessage を使ってワンタイムトークン付き URL(マジックリンク、パスワードリセット、メール確認など)を送る構成全般が対象です。
症状
- Slack に届いたログインリンクをクリックすると、毎回「リンクが無効です」(invalid link / token already used 相当)になる
- リンクの再送を何度試しても同じ
- トークンの有効期限内でも起きる
アプリケーション側のログにはエラーがなく、送信自体は成功しています。
原因: リンクプレビューのクローラーがトークンを消費している
Slack はメッセージ内の URL を検出すると、プレビュー(unfurl)を生成するためにその URL へ実際に HTTP リクエストを送ります(Slack 公式: Unfurling links in messages)。
マジックリンクの検証エンドポイントが「GET されたらトークンを検証して消費する」実装だと、この時点で終わりです。実際に観測した挙動はこうでした。
- マジックリンクを送信。トークンはデータベースに保存されている
- 誰もクリックしていないのに、送信から約 20 秒後にトークンの行が消える
- その後に人間がクリックすると、当然 invalid link
「20 秒後に消える」を見た瞬間に、犯人が人間でないことは確定します。Slack のプレビュー生成クローラーが、人間より先にリンクを踏んでいたわけです。
対策: unfurllinks / unfurlmedia を無効にする
Webhook のペイロードで unfurl を明示的に無効化します。
{
"text": "ログインリンク: https://example.com/auth/verify?token=...",
"unfurl_links": false,
"unfurl_media": false
}unfurl_links はテキスト系コンテンツ、unfurl_media は画像・動画系のプレビューを制御します(Slack 公式ドキュメント)。これを入れてから、マジックリンクは安定して人間の手元まで生き残るようになりました。
なお、この 2 つのフラグは「プレビューを出さない」だけでなく「プレビュー生成のためのフェッチをさせない」効果があるのがポイントです。見た目の問題ではなく、副作用のある GET を防ぐために入れます。
教訓: そもそも「GET でトークンを消費する」設計が脆い
今回は Slack でしたが、リンクを勝手に開くのは Slack だけではありません。
- メールセキュリティ製品のリンクスキャナ(Microsoft Defender の Safe Links など)
- チャットツール各種のプレビュー生成(Discord、Teams、LINE …)
- ソーシャルメディアの OGP クローラー
マジックリンクをどの経路で送っても、「人間より先に機械が GET する」ことは常に起こりえます。
HTTP の仕様上も、GET は「安全なメソッド」(サーバーの状態を変えないことが期待される)と定義されています(RFC 9110 §9.2.1 Safe Methods)。クローラーが GET を無害だと信じて踏むのは、仕様に沿った振る舞いです。トークンを GET で消費する設計は、この前提を裏切っています。
自前で認証フローを設計するなら、
- リンクの GET では確認ページを返すだけにして、トークンの消費はページ内のボタン(POST)で行う
- あるいはトークンを「一度で無効」ではなく「短い有効期限内は複数回検証可」にする
のどちらかに寄せておくと、この問題自体が消えます。既製の認証システムで検証エンドポイントの挙動を変えられない場合は、本記事のように送信経路側で機械のアクセスを止めるのが現実解です。
まとめ
- Slack はプレビュー生成のためにメッセージ内の URL を実際に GET する。ワンタイムトークン付き URL は人間がクリックする前に消費される
- Webhook ペイロードに
"unfurl_links": false, "unfurl_media": falseを入れると、プレビュー用フェッチ自体を止められる - 根本的には「GET でトークンを消費する」設計が RFC 9110 の安全なメソッドの前提と衝突している。自前実装なら消費は POST に寄せる
ちなみに、そもそもなぜマジックリンクを Slack に流す構成が必要になったのか — 独自ドメイン移行でパスキーが使えなくなる問題については「パスキーはドメインに縛られる — 独自ドメイン移行で管理者ログインできなくなる理由と対策」に書いています。Slack Webhook の他の活用例は「Cloudflare Pagesでお問い合わせフォーム(自動返信あり)を作る」でも触れています。